From e105adec3608184c6acca0a01f1e1b4882aeaa1f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 10 Nov 2026 19:57:32 +0000 Subject: [PATCH 1/8] feat: updated net 11 as the new base --- .config/dotnet-tools.json | 11 +- .csharpierignore | 9 + .editorconfig | 2 +- AGENTS.md | 29 +- CHANGELOG.md | 61 +++- Directory.Packages.props | 22 +- Justfile | 10 +- README.md | 7 +- .../Benchmarks/ActivityBenchmarks.cs | 2 - .../Benchmarks/LoggerBenchmarks.cs | 2 - .../Benchmarks/LoggerMultiTargetBenchmarks.cs | 2 - .../Benchmarks/MetricsBenchmarks.cs | 2 - .../MultiTargetVsSingleTargetBenchmarks.cs | 2 - .../Benchmarks/TagListBenchmarks.cs | 2 - .../Purview.Telemetry.Benchmarks.csproj | 16 +- docs/wiki/Activities.md | 27 ++ docs/wiki/Contributing.md | 2 +- docs/wiki/Diagnostics.md | 14 +- docs/wiki/FAQ.md | 5 +- docs/wiki/Getting-Started.md | 4 +- docs/wiki/Home.md | 5 +- docs/wiki/Installation.md | 12 +- docs/wiki/Metrics.md | 28 ++ docs/wiki/Performance.md | 73 ++--- docs/wiki/Platform-Support.md | 36 ++- docs/wiki/Sample-Application.md | 8 + docs/wiki/Tags-and-Baggage.md | 76 +++++ global.json | 8 +- package.json | 2 +- .../Directory.Build.props | 10 + samples/SampleApp.Net48/Directory.Build.props | 10 + samples/SampleApp/Directory.Build.props | 19 ++ .../SampleApp.AppHost.csproj | 7 +- skills/purview-telemetry-diagnostics/SKILL.md | 4 +- skills/purview-telemetry-quickstart/SKILL.md | 4 +- .../AnalyzerReleases.Unshipped.md | 4 + .../Analyzers/TelemetryDiagnosticAnalyzer.cs | 2 +- .../Compatibility/ClosedTypeAttribute.cs | 31 ++ ...ourceTargetClassEmitter.ActivityMethods.cs | 8 +- ...SourceTargetClassEmitter.ContextMethods.cs | 2 +- ...tySourceTargetClassEmitter.EventMethods.cs | 6 +- ...ActivitySourceTargetClassEmitter.Fields.cs | 28 +- ...ctivitySourceTargetClassEmitter.Methods.cs | 64 +++-- ...vitySourceTargetClassEmitter.Parameters.cs | 19 +- .../ActivitySourceTargetClassEmitter.cs | 4 + .../Emitters/GeneratedTypesEmitter.cs | 65 ++++- .../LoggerGenTargetClassEmitter.Methods.cs | 41 ++- .../Emitters/LoggerGenTargetClassEmitter.cs | 5 + .../LoggerTargetClassEmitter.Fields.cs | 6 +- .../LoggerTargetClassEmitter.Methods.cs | 2 +- .../Emitters/LoggerTargetClassEmitter.cs | 5 + .../MeterTargetClassEmitter.Fields.cs | 11 +- ...TargetClassEmitter.InitializationMethod.cs | 68 ++++- .../MeterTargetClassEmitter.Methods.cs | 31 +- .../Emitters/MeterTargetClassEmitter.cs | 5 + .../Emitters/UnionAccessorEmitter.cs | 124 ++++++++ .../TypeLibraryExtensions.cs | 15 - .../Generators/TelemetrySourceGenerator.cs | 7 +- .../Helpers/DiagnosticLibrary.Activities.cs | 16 ++ .../Helpers/DiagnosticLibrary.General.cs | 4 +- .../Helpers/DiagnosticLibrary.Metrics.cs | 36 +++ .../Helpers/InstrumentMethodModelBuilder.cs | 6 +- .../Helpers/LogMethodModelBuilder.cs | 3 +- .../Helpers/PipelineHelpers.Activities.cs | 74 +++-- .../Helpers/SharedHelpers.Metrics.cs | 126 +++++--- .../SourceGenerator/Helpers/SharedHelpers.cs | 9 + .../Helpers/TypeLibraryGenerator.Metrics.cs | 6 + .../TypeLibraryGenerator.PurviewGenTypes.cs | 3 + .../SourceGenerator/Helpers/UnionHelpers.cs | 170 +++++++++++ .../Records/ActivityRecords.cs | 114 +++++++- .../Records/AttributeDataModels.cs | 12 +- .../SourceGenerator/Records/LoggerRecords.cs | 16 +- .../Records/MetricAttributeRecords.cs | 166 ++++++++++- .../SourceGenerator/Records/MetricRecords.cs | 22 +- .../Records/TelemetryCapabilities.cs | 13 +- .../Records/TelemetryRules.Activities.cs | 64 +++-- .../Records/TelemetryRules.Metrics.cs | 62 +++- .../SourceGenerator/Records/TelemetryRules.cs | 8 +- .../SourceGenerator/Records/UnionRecords.cs | 55 ++++ .../UnsupportedTargetFrameworkTests.cs | 6 +- ...atorActivityTests.ActivitySourceOptions.cs | 126 ++++++++ ...ceGeneratorMetricsTests.GaugeInstrument.cs | 120 ++++++++ .../TelemetrySourceGeneratorUnionTests.cs | 269 ++++++++++++++++++ .../SourceGenerator.IntegrationTests.csproj | 11 +- 84 files changed, 2246 insertions(+), 357 deletions(-) create mode 100644 .csharpierignore create mode 100644 src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs create mode 100644 src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs create mode 100644 src/src/SourceGenerator/Helpers/UnionHelpers.cs create mode 100644 src/src/SourceGenerator/Records/UnionRecords.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceOptions.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GaugeInstrument.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 261b74ae..ae7b7732 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,12 +9,19 @@ ], "rollForward": false }, + "dotnet-inspect": { + "version": "0.27.0", + "commands": [ + "dotnet-inspect" + ], + "rollForward": false + }, "purview.build": { - "version": "0.8.0", + "version": "0.9.0", "commands": [ "purview-build" ], "rollForward": false } } -} +} \ No newline at end of file diff --git a/.csharpierignore b/.csharpierignore new file mode 100644 index 00000000..c457adf3 --- /dev/null +++ b/.csharpierignore @@ -0,0 +1,9 @@ +# CSharpier 1.3.0 (the newest release) parses with a Roslyn that predates C# 15, so the two files that use the +# `closed` modifier fail to parse and are reported as "not formatted due to syntax errors", which fails +# `just lint`. They are excluded rather than dropped, because `closed` is what makes a switch over the +# activity-method and instrument hierarchies compiler-checked for exhaustiveness. +# +# `dotnet format` (`just format`) still analyses and formats them: it runs on the SDK's Roslyn, which +# understands C# 15. Remove these entries once CSharpier ships C# 15 support. +src/src/SourceGenerator/Records/ActivityRecords.cs +src/src/SourceGenerator/Records/MetricAttributeRecords.cs diff --git a/.editorconfig b/.editorconfig index f8b30509..a9b39cb1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 2.0.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/AGENTS.md b/AGENTS.md index 7aa74d84..ca476bb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,9 +21,10 @@ code to generated telemetry interfaces) in a companion assembly. ## Prerequisites -- The .NET 11 SDK pinned in `global.json` (currently an RC, `rollForward: latestMajor`), plus the .NET 8, 9 and - 10 runtimes for the test legs. Projects target `net10.0` by default; the source generator is a Roslyn component +- The .NET 11 SDK pinned in `global.json` (`11.0.100`, `rollForward: latestMajor`), plus the .NET 10 and 11 + runtimes for the test legs. Projects target `net10.0` by default; the source generator is a Roslyn component targeting `netstandard2.0`. Consumers need Visual Studio 2026 / the .NET 10 SDK or later (Roslyn 5.9.0+). + **.NET 8 and .NET 9 are not supported** — consumer compilations that target them report `TSG1011`. - [Bun](https://bun.sh) for the `package.json`/`.build/*.ts` scripts - The `Purview.BuildSdk` MSBuild SDK (pinned in `global.json` under `msbuild-sdks`) - `csharpier` dotnet tool (pinned in `.config/dotnet-tools.json`) for linting/formatting @@ -127,7 +128,7 @@ samples/ └── SampleApp.Net48.slnx benchmarks/ -└── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net8.0/net9.0/net10.0) +└── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net10.0) .build/ # TS scripts (build-pack.ts, update-version.ts) .agents/skills/ # Installed skills from other repos, committed (see "Installed skills") @@ -185,7 +186,7 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel ### Test project details -- Integration tests target `net8.0;net9.0;net10.0;net11.0`, plus `net48` on Windows only. The `net11.0` leg is the +- Integration tests target `net10.0;net11.0`, plus `net48` on Windows only. The `net11.0` leg is the only one whose references carry `System.Diagnostics.ActivitySourceFactory`, so the `UseActivitySourceFactory` tests (`[RequiresActivitySourceFactory]`) run there and the fallback tests (`[RequiresNoActivitySourceFactory]`, `TSG3023`) run on the earlier legs. @@ -202,14 +203,32 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel - **Conventional commits**: enforced by commitlint (`.config/lefthook.yml`). Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. -- **Formatting**: C# is formatted with csharpier; check with `just lint`. +- **Formatting**: C# is formatted with csharpier; check with `just lint`. CSharpier 1.3.0 (the newest release) + parses with a Roslyn that predates C# 15, so the two files that declare a `closed` hierarchy are listed in + `.csharpierignore` rather than reported as syntax errors; `dotnet format` (`just format`) still covers them, + and the entries come out when CSharpier supports C# 15. - **Solutions**: `.slnx` solution files are used. - **Source generator**: targets `netstandard2.0` for broad compiler-host compatibility and uses `Purview.SourceGeneratorFramework` (CodeWriter-based emission, incremental pipeline, value-equatable models). +- **Union parameters**: a parameter whose type carries `[System.Runtime.CompilerServices.Union]` is + recorded as its case value, never as the union struct. `Helpers/UnionHelpers.cs` detects it + (case types from the nested `IUnionMembers` provider when there is one, otherwise from the union's + single-parameter constructors, and whether it declares the non-boxing `HasValue`/`TryGetValue` pattern); + `Emitters/UnionAccessorEmitter.cs` emits one private accessor per union type into each generated class; + every target renders the value through `UnionTargetRecord.RenderValueExpression()` rather than the + parameter name. Detection is by symbol, never by target framework. Whether a union is a *declared* + `union` or hand-written decides only whether the accessor reads `TryGetValue` or `Value`. - **SDK**: projects import `Purview.BuildSdk` via `Directory.Build.props`/`.targets`. The repo sets `ExcludePurviewTelemetry=true` (it does not consume the telemetry package it generates) and `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/purview-buildsdk-*` for SDK behavior. +- **C# 15 `closed` hierarchies**: the two tagged unions are closed. `Records/ActivityRecords.cs` declares + `closed record ActivityMethodTarget` with `StartActivityMethod` / `ActivityEventMethod` / `ActivityContextMethod`, + and `Records/MetricAttributeRecords.cs` declares `closed record InstrumentAttributeRecord` with one case per + instrument. Emitters switch over them, which the compiler checks for exhaustiveness. `closed` needs + `IsClosedTypeAttribute` and `CompilerFeatureRequiredAttribute`, which `Compatibility/ClosedTypeAttribute.cs` + declares internally for `netstandard2.0`; `IDE0010` does not model `closed` yet, so a switch over one carries + `#pragma warning disable IDE0010`. - **Analyzer release tracking**: the `TSG` rules are tracked in `src/src/SourceGenerator/AnalyzerReleases.{Shipped,Unshipped}.md` (wired as `AdditionalFiles`, so the Roslyn RS2000-series checks run under `EnforceExtendedAnalyzerRules=true`). Add new rules to diff --git a/CHANGELOG.md b/CHANGELOG.md index d2573d91..583d4a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,61 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). ## [Unreleased] -The level-specific logging attributes honour their arguments again. Generated log messages and event -ids change for every method that passed a template, event id, name or generation mode to one of them. +.NET 8 and .NET 9 are dropped, C# 15 union types are supported as telemetry values, and the samples build again. + +### Added + +- **`[Gauge]` and `HistogramBucketBoundaries`.** A `[Gauge]` method generates the .NET 9 `Gauge` instrument, which + records a value instead of accumulating one. `[Histogram(HistogramBucketBoundaries = "0.005, 0.01, 0.1")]` passes the + bucket boundaries to `InstrumentAdvice`. Both are gated on the compilation: `TSG4010` reports a gauge without + `Gauge`, `TSG4011` a histogram whose boundaries cannot be applied, and `TSG4012` boundaries that do not parse. +- **`Version` and `TelemetrySchemaUrl` on `[assembly: ActivitySourceGeneration(...)]`.** They are passed to + `System.Diagnostics.ActivitySourceOptions` for a factory-created source, which previously received only the name. + Setting either without `UseActivitySourceFactory` reports `TSG3024`. +- **C# 15 union types are recorded as their case value.** A parameter whose type carries + `[System.Runtime.CompilerServices.Union]` — a `union` declaration, or a hand-written union — is unwrapped + for activity tags and baggage, metric tags, and logging v1 and v2 values instead of being recorded as the + union struct, which had no meaningful `ToString`. The generator emits one private, aggressively inlined + accessor per union type. When the union declares the non-boxing access pattern (`bool HasValue` plus a + `bool TryGetValue(out T)` overload per case) the accessor calls those, so the union struct is never boxed + to read it; otherwise it reads `Value`. A union that holds no value records `null`. + See [Union types](docs/wiki/Tags-and-Baggage.md#union-types). + +### Changed + +- **The generator's two tagged unions are C# 15 `closed` hierarchies.** An activity method is exactly one of + `StartActivityMethod`, `ActivityEventMethod` or `ActivityContextMethod`, and an instrument is exactly one of the eight + `InstrumentAttributeRecord` cases, each carrying its own payload, creation method and instrument type. A method that + starts an activity can no longer be read for an event's attribute, and emitting an instrument's factory call no longer + depends on an enum member's name. Generated code is unchanged. +- **`.NET 8` and `.NET 9` are no longer supported.** `TSG1011` now requires `net10.0`+ or `net48`+ and its + message says so; a `net8.0`/`net9.0` compilation reports it and generates nothing. The integration tests + target `net10.0;net11.0` (plus `net48` on Windows), and the benchmarks target `net48;net10.0`. +- **Package versions follow the lowest supported framework line again.** `System.Diagnostics.DiagnosticSource`, + `Microsoft.Extensions.*` and the ASP.NET Core packages are pinned to `10.0.15`, the latest `net10.0` + servicing patch, rather than the `11.0.0` line: a package reference raises a consumer's floor, and + `net10.0` is the lowest target this generator supports. `net11.0` consumers still get the `11.0.x` + packages when they reference them themselves. +- **`Microsoft.SourceLink.GitHub` moves to `11.0.100`** to match the .NET 11 SDK the repository pins. +- **`EventId` on `[Log]` and the level-specific attributes is `int`, not `int?`.** A nullable property + cannot be set as a named attribute argument, so `[Info(EventId = 42)]` failed with CS0655 and only + the constructor overloads worked. Omitting it, or passing a negative value, still uses the id derived + from the method name. ### Fixed +The level-specific logging attributes honour their arguments again. Generated log messages and event +ids change for every method that passed a template, event id, name or generation mode to one of them. + +- **The Aspire `SampleApp.AppHost` builds and runs again.** `Purview.BuildSdk` 2.0.0 resolves its curated `Current` + target set (`net10.0;net11.0`) into `TargetFrameworks` for every project that does not declare a target framework in + its own file, which made the sample projects cross-targeting. An Aspire AppHost cannot cross-target: the outer + cross-targeting pass imports `Microsoft.NET.Sdk.BeforeCommonCrossTargeting.targets` instead of + `BeforeCommon.targets`, so `Microsoft.NET.RuntimeIdentifierInference.targets` — the only declaration of the + `Microsoft.NET.Build.Tasks.PickBestRid` task the AppHost SDK runs during restore — is never imported, and restore + failed with `MSB4036`. The samples now pin `net10.0` (and clear `TargetFrameworks`) as documented. +- **The generated `[Histogram]` attribute's `HistogramBucketBoundaries` XML comment is well-formed.** It named + `InstrumentAdvice` unescaped, which `CS1570` rejected in a consumer that generates documentation files. - **`[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]` and `[Critical]` ignored every argument but the level** (since 5.0.0). `[Info("Order {OrderId} placed")]` emitted the default `"Method: OrderId = {OrderId}"` template, every such method logged with `EventId` 0 rather than the @@ -26,13 +76,6 @@ ids change for every method that passed a template, event id, name or generation - `[Error]` now treats a whitespace-only `MessageTemplate` or `Name` as unset, like the other level-specific attributes. -### Changed - -- **`EventId` on `[Log]` and the level-specific attributes is `int`, not `int?`.** A nullable property - cannot be set as a named attribute argument, so `[Info(EventId = 42)]` failed with CS0655 and only - the constructor overloads worked. Omitting it, or passing a negative value, still uses the id derived - from the method name. - ## [5.0.7-prerelease.3] - 2026-10-28 The `TSG1011` message no longer offers an opt-out that never existed, and the define it named is diff --git a/Directory.Packages.props b/Directory.Packages.props index a1026eb1..990193d8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,16 +1,24 @@ 5.9.0 - 1.76.0 - 1.3.0 - 10.0.14 + 1.81.16 + 2.0.0 + + 10.0.15 10.11.0 1.20.1 - + @@ -26,7 +34,7 @@ - + @@ -60,7 +68,7 @@ the same version. --> - - + + diff --git a/Justfile b/Justfile index 50b1d436..d676b383 100644 --- a/Justfile +++ b/Justfile @@ -110,10 +110,18 @@ restore *args: dotnet restore {{ solution_file }} {{ args }} # Runs tests for the solution with the specified configuration (default: Release) +# +# Uses the .NET 11 Microsoft.Testing.Platform run-level switches. They apply to the whole run across every +# test application (net48, net10.0, net11.0 and Repository.UnitTests): +# --timeout caps total execution time, so a hung test application cannot stall CI +# --maximum-failed-tests stops at the first N failures instead of burning the whole run +# --results-directory-layout per-module gives every project/TFM its own /_ folder, so the +# four legs can no longer overwrite one another's report files +# These are not VSTest switches: VSTest mode rejects them. [group('Build and Test')] test filter="/*/*/*/*/" *args: echo "Running tests for {{ BLUE }}{{ test_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." - dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} {{ args }} + dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module {{ args }} # Scan build or publish output for compiler-host assemblies (the generator, the refactorings, # Purview.SourceGeneratorFramework, Roslyn) that must never reach an application. diff --git a/README.md b/README.md index 7e39bc88..d108b294 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system **Consumer runtime targets:** - .NET Framework 4.8 -- .NET 8 or higher, including .NET 11 +- .NET 10 or higher, including .NET 11 (.NET 8 and .NET 9 are no longer supported) **Hosting models:** console, ASP.NET Core, .NET Aspire, Native AOT, Blazor WebAssembly (trimmed and AOT-compiled) and Blazor Server. The generated code uses no reflection and adds no trim or AOT warnings, and no compiler-host @@ -35,8 +35,13 @@ assembly reaches your application's output. See `services.AddTracing()`. The generated classes then create their sources from the injected .NET 11 factory instead of a static `ActivitySource`. When the type isn't available, `TSG3023` reports it and the static source is kept. - **C# 15 static interface members** are ignored rather than implemented. +- **C# 15 union types** are unwrapped to their case value for tags, baggage, log values and metric tags. + A union that declares the non-boxing `TryGetValue`/`HasValue` pattern is read without boxing the union + struct. See [Union types](https://purview.dev/docs/telemetry-sourcegenerator/tags-and-baggage/#union-types). - **Validated on .NET 11**: the tests run on `net11.0`, and Native AOT, Blazor WebAssembly and Blazor Server consumers of the package are built on every release. +- **.NET 8 and .NET 9 are no longer supported**: target `net10.0`+ or `net48`+. `TSG1011` reports a + compilation that targets either dropped framework. ## Installation diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs index 5720ef18..cae44b31 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs @@ -19,8 +19,6 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; [HideColumns("Error", "StdDev", "Median", "RatioSD")] [SimpleJob(RuntimeMoniker.Net47)] [SimpleJob(RuntimeMoniker.Net48)] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net90)] [SimpleJob(RuntimeMoniker.Net10_0)] public class ActivityBenchmarks { diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs index ff77e826..16d0aeda 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs @@ -26,8 +26,6 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; [HideColumns("Error", "StdDev", "Median", "RatioSD")] [SimpleJob(RuntimeMoniker.Net47)] [SimpleJob(RuntimeMoniker.Net48)] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net90)] [SimpleJob(RuntimeMoniker.Net10_0)] public class LoggerBenchmarks { diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs index a08873c4..c7524d27 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs @@ -26,8 +26,6 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; [HideColumns("Error", "StdDev", "Median", "RatioSD")] [SimpleJob(RuntimeMoniker.Net47)] [SimpleJob(RuntimeMoniker.Net48)] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net90)] [SimpleJob(RuntimeMoniker.Net10_0)] public class LoggerMultiTargetBenchmarks { diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs index 9d35fa1d..4dfcd59b 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs @@ -22,8 +22,6 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; [HideColumns("Error", "StdDev", "Median", "RatioSD")] [SimpleJob(RuntimeMoniker.Net47)] [SimpleJob(RuntimeMoniker.Net48)] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net90)] [SimpleJob(RuntimeMoniker.Net10_0)] public class MetricsBenchmarks { diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs index 177c23ba..0f6dbd34 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs @@ -20,8 +20,6 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; [HideColumns("Error", "StdDev", "Median", "RatioSD")] [SimpleJob(RuntimeMoniker.Net47)] [SimpleJob(RuntimeMoniker.Net48)] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net90)] [SimpleJob(RuntimeMoniker.Net10_0)] public class MultiTargetVsSingleTargetBenchmarks { diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs index d3759410..43e1bc31 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs @@ -24,8 +24,6 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; [HideColumns("Error", "StdDev", "Median", "RatioSD")] [SimpleJob(RuntimeMoniker.Net47)] [SimpleJob(RuntimeMoniker.Net48)] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net90)] [SimpleJob(RuntimeMoniker.Net10_0)] public class TagListBenchmarks { diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj index b7c36866..e5608965 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj +++ b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj @@ -1,7 +1,7 @@ Exe - net48;net8.0;net9.0;net10.0 + net48;net10.0 14.0 enable enable @@ -13,6 +13,14 @@ $(NoWarn);CA1707;CA1515 + + + true + + + + diff --git a/samples/SampleApp.Net48/Directory.Build.props b/samples/SampleApp.Net48/Directory.Build.props index 50b3cb66..bcaf0af5 100644 --- a/samples/SampleApp.Net48/Directory.Build.props +++ b/samples/SampleApp.Net48/Directory.Build.props @@ -20,4 +20,14 @@ + + + + + diff --git a/samples/SampleApp/Directory.Build.props b/samples/SampleApp/Directory.Build.props index d1dd7909..a35bca0c 100644 --- a/samples/SampleApp/Directory.Build.props +++ b/samples/SampleApp/Directory.Build.props @@ -15,7 +15,26 @@ TSG2008: the sample intentionally demonstrates a large ExpandEnumerable limit; source-generator diagnostics are not suppressible with #pragma, so it's NoWarn'd. --> $(NoWarn);CA1031;CA1724;CA2007;CA2201;TSG2008; + + net10.0 + + + + + diff --git a/samples/SampleApp/SampleApp.AppHost/SampleApp.AppHost.csproj b/samples/SampleApp/SampleApp.AppHost/SampleApp.AppHost.csproj index 69306f2a..808a41b4 100644 --- a/samples/SampleApp/SampleApp.AppHost/SampleApp.AppHost.csproj +++ b/samples/SampleApp/SampleApp.AppHost/SampleApp.AppHost.csproj @@ -1,5 +1,10 @@ - + + false diff --git a/skills/purview-telemetry-diagnostics/SKILL.md b/skills/purview-telemetry-diagnostics/SKILL.md index 57fd3429..7a9b5ca1 100644 --- a/skills/purview-telemetry-diagnostics/SKILL.md +++ b/skills/purview-telemetry-diagnostics/SKILL.md @@ -43,7 +43,7 @@ carry the message, so a reworded message is recorded in the CHANGELOG instead. | `TSG1007` | Warning | `ExcludeTargets` leaves a target with an empty or invalid parameter set. | | `TSG1008` | Warning | An `Activity`-typed parameter on a method with no `[Activity]`/`[Event]`/`[Context]`. | | `TSG1010` | Error | A method carries an attribute for a target family the interface does not register. | -| `TSG1011` | Error | The compilation targets neither .NET 8+ nor .NET Framework 4.8+, so nothing is generated. There is no opt-out: target `net8.0`+ or `net48`+, and reference the package only from those targets of a multi-targeted library. | +| `TSG1011` | Error | The compilation targets neither .NET 10+ nor .NET Framework 4.8+, so nothing is generated. There is no opt-out: target `net10.0`+ or `net48`+, and reference the package only from those targets of a multi-targeted library. | ## Logging — `TSG2xxx` @@ -160,7 +160,7 @@ parameter. The measurement is fixed at 1. ### `TSG1011` — unsupported target framework -Raised for a compilation that targets neither `net8.0`+ nor `net48`+ (typically `netstandard2.0`). +Raised for a compilation that targets neither `net10.0`+ nor `net48`+ (typically `netstandard2.0`, or the dropped `net8.0`/`net9.0`). It cannot be suppressed by a define; no telemetry is generated for that target. A library that has to keep such a target references the generator only from its supported ones: diff --git a/skills/purview-telemetry-quickstart/SKILL.md b/skills/purview-telemetry-quickstart/SKILL.md index f0957a1d..7717b528 100644 --- a/skills/purview-telemetry-quickstart/SKILL.md +++ b/skills/purview-telemetry-quickstart/SKILL.md @@ -31,13 +31,13 @@ Then make the reference analyzer-only (keep the `Version` that `dotnet add packa ``` -Consumers can target .NET Framework 4.8 or .NET 8 and later, .NET 11 included, and the generated code works under +Consumers can target .NET Framework 4.8 or .NET 10 and later, .NET 11 included, and the generated code works under Native AOT and in trimmed or AOT-compiled Blazor WebAssembly. Runtime dependencies for the telemetry types you use: - Activities: `System.Diagnostics.DiagnosticSource` (in-box on .NET). - Logging: `Microsoft.Extensions.Logging.Abstractions`. It is in the shared framework on .NET 11, so do not reference it there (NuGet reports `NU1510`). Add `Microsoft.Extensions.Telemetry.Abstractions` for the faster v2 logging. -- Metrics: `System.Diagnostics.Metrics` (in-box on .NET 8+). Register `services.AddMetrics()` for the `IMeterFactory`. +- Metrics: `System.Diagnostics.Metrics` (in-box on .NET 10+). Register `services.AddMetrics()` for the `IMeterFactory`. ## Define a telemetry interface Create a `public interface` and decorate it with class-level attributes. The generator creates the implementation and a DI registration extension. diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index e79915cb..462f5779 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -6,3 +6,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- TSG3023 | Activity.Usage | Warning | [ActivitySourceFactory is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg3023) +TSG3024 | Activity.Usage | Warning | [ActivitySourceOptions require UseActivitySourceFactory](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg3024) +TSG4010 | Metrics.Usage | Warning | [Gauge is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4010) +TSG4011 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4011) +TSG4012 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not a list of numbers](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4012) diff --git a/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs index 3ebb69e1..8ac32de0 100644 --- a/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs @@ -92,7 +92,7 @@ static void AnalyzeNamedType(SymbolAnalysisContext context) var result = PipelineHelpers.BuildMeterTarget(interfaceSymbol, compilation, token); if (result.HasValue && result.Value is { } meterTarget) { - var diagnostics = TelemetryRules.GetMeterDiagnostics(meterTarget, interfaceSymbol, token); + var diagnostics = TelemetryRules.GetMeterDiagnostics(meterTarget, interfaceSymbol, compilation, token); foreach (var diagnostic in diagnostics) context.ReportDiagnostic(diagnostic.ToDiagnostic()); } diff --git a/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs b/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs new file mode 100644 index 00000000..62c6b6d6 --- /dev/null +++ b/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs @@ -0,0 +1,31 @@ +// C# 15's `closed` modifier is a language feature, but the compiler records it with runtime attributes that +// netstandard2.0 does not carry. Declaring them here is what lets the generator source use `closed` while still +// targeting netstandard2.0 for compiler-host compatibility. They are internal (the default for a top-level type), so +// they never reach the merged analyzer's public surface, and they are never emitted into a consumer's compilation. +#pragma warning disable IDE0130 // Namespace does not match folder structure: these must live in the BCL namespace +namespace System.Runtime.CompilerServices +{ + /// + /// Records that a class is closed: its direct descendants are fixed to the declaring assembly, so a + /// switch over it is exhaustive once every direct descendant is handled. + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + sealed class IsClosedTypeAttribute : Attribute + { + public Type[]? DerivedTypes { get; set; } + } + + /// + /// Marks a member the compiler requires for a language feature the declaring compiler must understand, which for + /// a closed class is the constructor that lets a subtype derive from it. + /// + [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)] + sealed class CompilerFeatureRequiredAttribute : Attribute + { + public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName; + + public string FeatureName { get; } + + public bool IsOptional { get; init; } + } +} diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs index 203df099..a6b29994 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs @@ -8,7 +8,7 @@ partial class ActivitySourceTargetClassEmitter { static void EmitActivityMethodBody( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + StartActivityMethod methodTarget, CodeWriter writer, SourceProductionContext context ) @@ -126,7 +126,7 @@ out var _ } } - static void AddActivityNameParameter(CodeWriter writer, ActivityBasedGenerationTarget methodTarget, bool useName) + static void AddActivityNameParameter(CodeWriter writer, ActivityMethodTarget methodTarget, bool useName) { if (useName) writer.Write("name: "); @@ -136,7 +136,7 @@ static void AddActivityNameParameter(CodeWriter writer, ActivityBasedGenerationT static void EmitActivityCall( CodeWriter writer, - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, bool createOnly, bool useParentContext, string parentContextParameterName, @@ -192,7 +192,7 @@ int kind writer.Line(")"); } - static void EmitHasListenersTest(CodeWriter writer, ActivityBasedGenerationTarget methodTarget) + static void EmitHasListenersTest(CodeWriter writer, ActivityMethodTarget methodTarget) { var returnsVoid = methodTarget.ReturnType.Identity.SpecialType == SpecialType.System_Void; writer.IfBlock( diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs index bbbbe684..9c5286af 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs @@ -7,7 +7,7 @@ partial class ActivitySourceTargetClassEmitter { static void EmitContextMethodBody( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, CodeWriter writer, SourceProductionContext context ) diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs index d2e13d15..acd996f6 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs @@ -8,7 +8,7 @@ partial class ActivitySourceTargetClassEmitter { static void EmitEventMethodBody( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + ActivityEventMethod methodTarget, CodeWriter writer, SourceProductionContext context ) @@ -116,7 +116,7 @@ out var statusDescriptionParam static string EmitEventTags( CodeWriter writer, - ActivityBasedGenerationTarget methodTarget, + ActivityEventMethod methodTarget, string activityVariableName, ActivityBasedParameterTarget? tagsParam, ActivityBasedParameterTarget? escapeParam @@ -213,7 +213,7 @@ void EmitTag() static void EmitSetStatus( CodeWriter writer, - ActivityBasedGenerationTarget methodTarget, + ActivityEventMethod methodTarget, string activityVariableName, ActivityBasedParameterTarget? statusDescriptionParam, ActivityBasedParameterTarget? exceptionParam diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs index 8b96e252..5feb504f 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs @@ -111,7 +111,33 @@ string activitySourceName .Write(TypeLibrary.System.Diagnostics.ActivitySourceOptions) .Write("(") .Write(activitySourceName.Wrap()) - .Line("));"); + .Write(')'); + + // .NET 11's ActivitySourceOptions carries the source's version and telemetry schema URL. They are + // emitted only when the assembly set them, so a source whose assembly set neither keeps the + // single-argument constructor. + var version = output.Target.ActivitySourceGenerationAttribute?.Version; + var telemetrySchemaUrl = output.Target.ActivitySourceGenerationAttribute?.TelemetrySchemaUrl; + + if (!string.IsNullOrWhiteSpace(version) || !string.IsNullOrWhiteSpace(telemetrySchemaUrl)) + { + writer + .Write(' ') + .Line("{") + .Indented(body => + { + if (!string.IsNullOrWhiteSpace(version)) + body.Write("Version = ").Line(version!.Wrap() + ","); + + if (!string.IsNullOrWhiteSpace(telemetrySchemaUrl)) + body.Write("TelemetrySchemaUrl = ").Line(telemetrySchemaUrl!.Wrap() + ","); + }) + .Line("});"); + } + else + { + writer.Line(");"); + } } } } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs index 68cb51cd..215e0e88 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs @@ -17,9 +17,9 @@ static void EmitMethods(ActivityOutputContext output, CodeWriter writer, SourceP var validActivityMethods = target.ActivityMethods.Where(m => m.TargetGenerationState.IsValid).ToArray(); // Check for TSG3012: Event/Context methods exist but no Activity method - if (!validActivityMethods.Any(m => m.MethodType == ActivityMethodType.Activity)) + if (!validActivityMethods.Any(m => m is StartActivityMethod)) { - if (validActivityMethods.Any(m => m.MethodType != ActivityMethodType.Activity)) + if (validActivityMethods.Any(m => m is not StartActivityMethod)) { output.Context.Diagnostic( "There are no Activity methods defined, however there are Events/ Context methods." @@ -144,7 +144,7 @@ string exceptionParam .Line(".StackTrace);"); } - static void EmitThrowStub(CodeWriter writer, ActivityBasedGenerationTarget methodTarget) + static void EmitThrowStub(CodeWriter writer, ActivityMethodTarget methodTarget) { writer.NewLine(); @@ -177,7 +177,7 @@ static void EmitThrowStub(CodeWriter writer, ActivityBasedGenerationTarget metho static void EmitMethod( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, CodeWriter writer, SourceProductionContext context ) @@ -226,7 +226,7 @@ SourceProductionContext context static void EmitPrivateActivityMethod( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, CodeWriter writer, SourceProductionContext context ) @@ -254,12 +254,22 @@ SourceProductionContext context ) ) { - if (methodTarget.MethodType == ActivityMethodType.Activity) - EmitActivityMethodBody(output, methodTarget, writer, context); - else if (methodTarget.MethodType == ActivityMethodType.Event) - EmitEventMethodBody(output, methodTarget, writer, context); - else if (methodTarget.MethodType == ActivityMethodType.Context) - EmitContextMethodBody(output, methodTarget, writer, context); + // The hierarchy is closed, so the compiler requires every case and the switch needs no default arm. + // IDE0010 does not model `closed` hierarchies yet, so it still offers to add a default arm. +#pragma warning disable IDE0010 + switch (methodTarget) + { + case StartActivityMethod startActivity: + EmitActivityMethodBody(output, startActivity, writer, context); + break; + case ActivityEventMethod activityEvent: + EmitEventMethodBody(output, activityEvent, writer, context); + break; + case ActivityContextMethod activityContext: + EmitContextMethodBody(output, activityContext, writer, context); + break; + } +#pragma warning restore IDE0010 } writer.NewLine(); @@ -267,7 +277,7 @@ SourceProductionContext context static void EmitPublicDelegatingMethod( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, GenerationType methodTargets, CodeWriter writer, SourceProductionContext context @@ -359,7 +369,7 @@ SourceProductionContext context static void EmitPublicActivityMethod( ActivityOutputContext output, - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, CodeWriter writer, SourceProductionContext context ) @@ -380,18 +390,28 @@ SourceProductionContext context ) ) { - if (methodTarget.MethodType == ActivityMethodType.Activity) - EmitActivityMethodBody(output, methodTarget, writer, context); - else if (methodTarget.MethodType == ActivityMethodType.Event) - EmitEventMethodBody(output, methodTarget, writer, context); - else if (methodTarget.MethodType == ActivityMethodType.Context) - EmitContextMethodBody(output, methodTarget, writer, context); + // The hierarchy is closed, so the compiler requires every case and the switch needs no default arm. + // IDE0010 does not model `closed` hierarchies yet, so it still offers to add a default arm. +#pragma warning disable IDE0010 + switch (methodTarget) + { + case StartActivityMethod startActivity: + EmitActivityMethodBody(output, startActivity, writer, context); + break; + case ActivityEventMethod activityEvent: + EmitEventMethodBody(output, activityEvent, writer, context); + break; + case ActivityContextMethod activityContext: + EmitContextMethodBody(output, activityContext, writer, context); + break; + } +#pragma warning restore IDE0010 } writer.NewLine(); } - static bool GuardMethod(ActivityBasedGenerationTarget methodTarget, ActivityOutputContext output) + static bool GuardMethod(ActivityMethodTarget methodTarget, ActivityOutputContext output) { var target = output.Target; @@ -415,7 +435,7 @@ static bool GuardMethod(ActivityBasedGenerationTarget methodTarget, ActivityOutp // Event methods must return void only // Activity and Context methods can return void or Activity? - var isEvent = methodTarget.MethodType == ActivityMethodType.Event; + var isEvent = methodTarget is ActivityEventMethod; var isValidReturnType = isEvent ? methodTarget.ReturnType.Identity.SpecialType == SpecialType.System_Void @@ -434,7 +454,7 @@ static bool GuardMethod(ActivityBasedGenerationTarget methodTarget, ActivityOutp if (target.ActivitySourceGenerationAttribute?.GenerateDiagnosticsForMissingActivity ?? true) { // Here we're opting in to generate diagnostics for missing activity return/ params. - if (methodTarget.MethodType == ActivityMethodType.Activity) + if (methodTarget is StartActivityMethod) { if (!methodTarget.ReturnType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity)) { diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs index 104bff01..1b5e2f6a 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs @@ -11,9 +11,10 @@ static void EmitTagsOrBaggageParameters( CodeWriter writer, string activityVariableName, bool populateTags, - ActivityBasedGenerationTarget method, + ActivityMethodTarget method, bool checkForNullableActivity, - ActivityOutputContext output + ActivityOutputContext output, + EventAttributeData? eventAttribute = null ) { var parameters = populateTags ? method.Tags : method.Baggage; @@ -21,7 +22,7 @@ ActivityOutputContext output return; var useRecordedExceptionRules = - method.EventAttribute?.UseRecordExceptionRules ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; + eventAttribute?.UseRecordExceptionRules ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; void EmitParameter(ActivityBasedParameterTarget param) { @@ -32,13 +33,15 @@ void EmitParameter(ActivityBasedParameterTarget param) .Write('(') .Write(param.GeneratedName.Wrap()) .Write(", ") - .Write(param.ParameterName); + .Write(param.RenderValueExpression()); if (!populateTags && param.ParameterType.Identity.SpecialType != SpecialType.System_String) { output.Context.Diagnostic("Found a baggage parameter type that is not a string."); - if (param.ParameterType.IsNullable) + // A union parameter is recorded as its case value, which the accessor returns as object?, + // so the null-conditional is always needed; a plain parameter only when it is nullable. + if (param.Union is not null || param.ParameterType.IsNullable) writer.Write('?'); writer.Write(".ToString()"); @@ -76,7 +79,7 @@ void EmitParameters() } static bool GuardParameters( - ActivityBasedGenerationTarget methodTarget, + ActivityMethodTarget methodTarget, ActivityOutputContext output, out ActivityBasedParameterTarget? activityParam, out ActivityBasedParameterTarget? parentContextOrId, @@ -188,7 +191,7 @@ out ActivityBasedParameterTarget? statusDescriptionParam return false; } - if (methodTarget.MethodType != ActivityMethodType.Event) + if (methodTarget is not ActivityEventMethod) { return false; } @@ -211,7 +214,7 @@ out ActivityBasedParameterTarget? statusDescriptionParam return false; } - if (methodTarget.MethodType != ActivityMethodType.Event) + if (methodTarget is not ActivityEventMethod) { return false; } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs index 80994982..4974b0c8 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs @@ -38,6 +38,10 @@ public static void GenerateImplementation(ActivityOutputContext output, SourcePr ) { EmitFields(output, writer, context); + UnionAccessorEmitter.EmitAccessors( + writer, + target.ActivityMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) + ); EmitMethods(output, writer, context); } diff --git a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs index b6363ea3..dfd377a8 100644 --- a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs @@ -149,7 +149,17 @@ static class GeneratedTypesEmitter ); yield return ( TypeLibrary.Purview.Telemetry.HistogramAttribute, - (writer, type) => WriteCounterLikeAttribute(writer, type, "Marks a method as a histogram instrument.") + (writer, type) => + WriteCounterLikeAttribute( + writer, + type, + "Marks a method as a histogram instrument.", + writeHistogramBucketBoundaries: true + ) + ); + yield return ( + TypeLibrary.Purview.Telemetry.GaugeAttribute, + (writer, type) => WriteGaugeAttribute(writer, type, "Marks a method as a gauge instrument.") ); yield return ( TypeLibrary.Purview.Telemetry.ObservableCounterAttribute, @@ -782,6 +792,18 @@ static void WriteActivitySourceGenerationAttribute(CodeWriter writer, TypeIdenti + "with services.AddTracing(). Ignored, and reported as TSG3023, when the factory type is unavailable.", "false" ); + WriteNullableStringProperty( + body, + "Version", + "The version passed to System.Diagnostics.ActivitySourceOptions for factory-created sources. " + + "Ignored, and reported as TSG3024, unless UseActivitySourceFactory is set." + ); + WriteNullableStringProperty( + body, + "TelemetrySchemaUrl", + "The telemetry schema URL passed to System.Diagnostics.ActivitySourceOptions for factory-created " + + "sources. Ignored, and reported as TSG3024, unless UseActivitySourceFactory is set." + ); }, "Specifies the default activity source generation behaviour for an assembly." ); @@ -1492,7 +1514,12 @@ static void WriteAutoCounterAttribute(CodeWriter writer, TypeIdentity type, stri ); } - static void WriteCounterLikeAttribute(CodeWriter writer, TypeIdentity type, string summary) + static void WriteCounterLikeAttribute( + CodeWriter writer, + TypeIdentity type, + string summary, + bool writeHistogramBucketBoundaries = false + ) { #if DEUBG if (string.IsNullOrWhiteSpace(summary)) @@ -1533,6 +1560,40 @@ static void WriteCounterLikeAttribute(CodeWriter writer, TypeIdentity type, stri WriteNullableStringProperty(body, "Name", "Gets or sets the name of the instrument."); WriteNullableStringProperty(body, "Unit", "Gets or sets the measurement unit of the instrument."); WriteNullableStringProperty(body, "Description", "Gets or sets the description of the instrument."); + + if (writeHistogramBucketBoundaries) + WriteNullableStringProperty( + body, + "HistogramBucketBoundaries", + "The histogram's bucket boundaries as a comma-separated list of ascending, positive numbers, " + + "for example \"0.005, 0.01, 0.1, 1\". Passed to the InstrumentAdvice histogram bucket " + + "boundaries on .NET 9 and later; ignored, and reported as TSG4011, when " + + "System.Diagnostics.Metrics.InstrumentAdvice cannot be resolved." + ); + }, + summary + ); + } + + static void WriteGaugeAttribute(CodeWriter writer, TypeIdentity type, string summary) + { + EmitAttribute( + writer, + type, + AttributeTargets.Method, + body => + { + WriteEmptyConstructor(body, type, $"Constructs a new instance of the {XmlSee(type.Name)}."); + WriteNameConstructor(body, type, "The name of the instrument."); + WriteNameUnitDescriptionConstructor( + body, + type, + $"Constructs a new instance specifying the {XmlSee("Name")}, {XmlSee("Unit")} and {XmlSee("Description")}." + ); + + WriteNullableStringProperty(body, "Name", "Gets or sets the name of the instrument."); + WriteNullableStringProperty(body, "Unit", "Gets or sets the measurement unit of the instrument."); + WriteNullableStringProperty(body, "Description", "Gets or sets the description of the instrument."); }, summary ); diff --git a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs index c6cb4a1a..e2968a6d 100644 --- a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs @@ -207,7 +207,7 @@ SourceProductionContext context { context.CancellationToken.ThrowIfCancellationRequested(); - writer.Write(scopeNonExceptionParams[i].Name); + writer.Write(scopeNonExceptionParams[i].RenderValueExpression()); if (i < scopeNonExceptionParams.Count - 1) writer.Write(", "); } @@ -391,7 +391,7 @@ writer.IsNullableContextEnabled is null or true for (var i = 0; i < nonExceptionParams.Count; i++) { - writer.Write(nonExceptionParams[i].Name); + writer.Write(nonExceptionParams[i].RenderValueExpression()); if (i < nonExceptionParams.Count - 1) writer.Write(", "); } @@ -481,7 +481,14 @@ SourceProductionContext context var isEnumerable = parameter.IsArray || parameter.IsIEnumerable; // Need to match the name against the value. - OutputState(writer, stateVarName, parameter.Name.Wrap(), parameter.Name, ++idx, isEnumerable: isEnumerable); + OutputState( + writer, + stateVarName, + parameter.Name.Wrap(), + parameter.RenderValueExpression(), + ++idx, + isEnumerable: isEnumerable + ); if (isEnumerable) { @@ -890,7 +897,12 @@ static void EmitLogStateStruct(CodeWriter writer, LogMethodTarget methodTarget, context.CancellationToken.ThrowIfCancellationRequested(); writer.Field( - new FieldDeclarationOptions($"_{param.UpperCasedName}", param.ParameterType) + new FieldDeclarationOptions( + $"_{param.UpperCasedName}", + param.RequiresObjectState + ? TypeLibrary.System.Object.AsTypeReference().Nullable(writer) + : param.ParameterType + ) { Accessibility = TypeDeclarationAccessibility.Public, IsReadOnly = true, @@ -905,7 +917,12 @@ static void EmitLogStateStruct(CodeWriter writer, LogMethodTarget methodTarget, { Parameters = [ - .. nonExceptionParams.Select(p => new ParameterDeclarationOptions(p.Name, p.ParameterType)), + .. nonExceptionParams.Select(p => new ParameterDeclarationOptions( + p.Name, + p.RequiresObjectState + ? TypeLibrary.System.Object.AsTypeReference().Nullable(writer) + : p.ParameterType + )), ], }, ctor => @@ -1167,7 +1184,12 @@ static void EmitScopeStateStruct(CodeWriter writer, LogMethodTarget methodTarget context.CancellationToken.ThrowIfCancellationRequested(); writer.Field( - new FieldDeclarationOptions($"_{param.UpperCasedName}", param.ParameterType) + new FieldDeclarationOptions( + $"_{param.UpperCasedName}", + param.RequiresObjectState + ? TypeLibrary.System.Object.AsTypeReference().Nullable(writer) + : param.ParameterType + ) { Accessibility = TypeDeclarationAccessibility.Public, IsReadOnly = true, @@ -1182,7 +1204,12 @@ static void EmitScopeStateStruct(CodeWriter writer, LogMethodTarget methodTarget { Parameters = [ - .. nonExceptionParams.Select(p => new ParameterDeclarationOptions(p.Name, p.ParameterType)), + .. nonExceptionParams.Select(p => new ParameterDeclarationOptions( + p.Name, + p.RequiresObjectState + ? TypeLibrary.System.Object.AsTypeReference().Nullable(writer) + : p.ParameterType + )), ], }, ctor => diff --git a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs index cbcfd5ae..25a341c3 100644 --- a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs @@ -41,6 +41,11 @@ public static void GenerateImplementation(LoggerOutputContext output, SourceProd { EmitFields(output, writer, context); + UnionAccessorEmitter.EmitAccessors( + writer, + target.LogMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) + ); + ConstructorEmitter.EmitCtor( GenerationType.Logging, target.GenerationType, diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Fields.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Fields.cs index 33aa57cd..4bba229f 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Fields.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Fields.cs @@ -92,9 +92,7 @@ internal static void EmitLogActionField(CodeWriter writer, LogMethodTarget metho (methodTarget.IsScoped ? "global::System.Func<" : "global::System.Action<") + TypeLibrary.Microsoft.Extensions.Logging.ILogger.RenderFullNameForNullable(useNullable) + string.Concat( - methodTarget.ParametersSansException.Select(p => - ", " + p.ParameterType.RenderFullNameForNullable(useNullable) - ) + methodTarget.ParametersSansException.Select(p => ", " + UnionHelpers.RenderLogStateType(p, useNullable)) ) + ", " + ( @@ -110,7 +108,7 @@ internal static void EmitLogActionField(CodeWriter writer, LogMethodTarget metho + string.Join( ", ", methodTarget.ParametersSansException.Select(p => - p.ParameterType.RenderFullNameForNullable(useNullable) + UnionHelpers.RenderLogStateType(p, useNullable) ) ) + ">" diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs index efff6721..9c9e3d45 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs @@ -156,7 +156,7 @@ SourceProductionContext context foreach (var parameter in methodTarget.ParametersSansException) { - writer.Write(", ").Write(parameter.Name); + writer.Write(", ").Write(parameter.RenderValueExpression()); } if (methodTarget.ExceptionParameter != null) diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs index b29e20c7..ae22ff10 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs @@ -39,6 +39,11 @@ public static void GenerateImplementation(LoggerOutputContext output, SourceProd { EmitFields(output, writer, context); + UnionAccessorEmitter.EmitAccessors( + writer, + target.LogMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) + ); + ConstructorEmitter.EmitCtor( GenerationType.Logging, target.GenerationType, diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Fields.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Fields.cs index f908ac9e..7af5f739 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Fields.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Fields.cs @@ -62,9 +62,14 @@ static void EmitFields( continue; } - var type = TypeLibrary - .Purview.Telemetry.InstrumentTypeMap[method.InstrumentAttribute.InstrumentType] - .MakeGeneric(method.InstrumentMeasurementType); + if (!IsInstrumentAvailable(method, output.Context)) + { + // The instrument type is unavailable (a gauge without Gauge): TSG4010 reports it and the + // method is emitted as a throw stub, so there is no field to declare. + continue; + } + + var type = method.InstrumentAttribute.InstrumentType.MakeGeneric(method.InstrumentMeasurementType); // Observable instruments are registered via callback, not assigned in the constructor, // so they cannot be readonly. diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs index eb105b5e..857e8cdc 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs @@ -138,12 +138,16 @@ static void EmitInitializationBodyContent(MeterOutputContext output, CodeWriter } foreach (var method in target.InstrumentationMethods) - EmitInitialiseInstrumentVariable(method, writer); + EmitInitialiseInstrumentVariable(method, writer, output.Context); } - static void EmitInitialiseInstrumentVariable(InstrumentTarget method, CodeWriter writer) + static void EmitInitialiseInstrumentVariable( + InstrumentTarget method, + CodeWriter writer, + GenerationContext context + ) { - if (!method.TargetGenerationState.IsValid) + if (!method.TargetGenerationState.IsValid || !IsInstrumentAvailable(method, context)) return; if (!method.IsObservable) @@ -166,8 +170,7 @@ static void EmitInitialiseInstrumentVariable(InstrumentTarget method, CodeWriter .Write(method.FieldName) .Write(" = ") .Write(MeterFieldName) - .Write(".Create") - .Write(method.InstrumentAttribute!.InstrumentType.ToString()) + .Write("." + method.InstrumentAttribute!.CreationMethodName) .Write('<') .Write(method.InstrumentMeasurementType) .Write(">(name: ") @@ -177,8 +180,59 @@ static void EmitInitialiseInstrumentVariable(InstrumentTarget method, CodeWriter .Write(", description: ") .Write(description) .Write(", tags: ") - .Write(tagVariableName) - .Line(");"); + .Write(tagVariableName); + + EmitInstrumentAdvice(method, writer, context); + + writer.Line(");"); + } + } + + /// + /// Appends advice: for a histogram whose HistogramBucketBoundaries parsed. Only emitted when the + /// compilation resolves InstrumentAdvice<T>, so an older analyzer-only + /// System.Diagnostics.DiagnosticSource still compiles (and TSG4011 reports the omission). + /// + static void EmitInstrumentAdvice( + InstrumentTarget method, + CodeWriter writer, + GenerationContext context + ) + { + if (!context.Capabilities.SupportsInstrumentAdvice) + return; + + if ( + method.InstrumentAttribute + is not HistogramInstrumentAttributeRecord { HistogramBucketBoundaries: { Count: > 0 } boundaries } + ) + return; + + writer + .NewLine() + .Write(", advice: new ") + .Write( + TypeLibrary.System.Diagnostics.Metrics.InstrumentAdvice.MakeGeneric(method.InstrumentMeasurementType) + ) + .Write(" { HistogramBucketBoundaries = new double[] { "); + + for (var i = 0; i < boundaries.Count; i++) + { + if (i > 0) + writer.Write(", "); + + writer.Write(boundaries[i].ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "d"); } + + writer.Write(" } }"); } + + /// + /// Whether the compilation can carry this instrument: a gauge needs Gauge<T>. A method whose + /// instrument cannot be generated gets a throw stub instead, and TSG4010 explains why. + /// + internal static bool IsInstrumentAvailable( + InstrumentTarget method, + GenerationContext context + ) => method.InstrumentAttribute is not GaugeInstrumentAttributeRecord || context.Capabilities.SupportsGauge; } diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs index 8f8ca75d..9d63e8a1 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs @@ -70,6 +70,13 @@ static void EmitMethods(MeterOutputContext output, CodeWriter writer, SourceProd ); } + if (!IsInstrumentAvailable(methodTarget, output.Context)) + { + // Gauge is unavailable: TSG4010 reports it, and the method still has to satisfy the interface. + EmitThrowStub(writer, methodTarget); + continue; + } + EmitMethod(output, methodTarget, writer, context); } } @@ -261,9 +268,7 @@ static void EmitObservableInstrumentBody(CodeWriter writer, InstrumentTarget met writer .Write(method.FieldName) .Write(" = ") - .Write(MeterFieldName) - .Write(".Create") - .Write(method.InstrumentAttribute!.InstrumentType.ToString()) + .Write(MeterFieldName + "." + method.InstrumentAttribute!.CreationMethodName) .Write('<') .Write(method.InstrumentMeasurementType) .Write(">(") @@ -295,8 +300,7 @@ static void EmitInstrumentBody( bool emitNullable = true ) { - var instrumentMeasureMethodName = - methodTarget.InstrumentAttribute!.InstrumentType == InstrumentTypes.Histogram ? "Record" : "Add"; + var instrumentMeasureMethodName = methodTarget.InstrumentAttribute!.UsesRecordMethod ? "Record" : "Add"; var tagCount = methodTarget.Tags.Count; var hasConditionalTags = methodTarget.Tags.Any(t => t.SkipOnNullOrEmpty); @@ -328,7 +332,7 @@ static void EmitInstrumentBody( .Write(kvpType) .Write(tag.GeneratedName.Wrap()) .Write(", ") - .Write(tag.ParameterName) + .Write(tag.RenderTagValueExpression()) .Write(')'); } @@ -380,12 +384,23 @@ static void EmitInstrumentBody( { writer.IfBlock( param.ParameterName + " != default", - body => body.MethodCallOn(tagVariableName, "Add", param.GeneratedName.Wrap(), param.ParameterName) + body => + body.MethodCallOn( + tagVariableName, + "Add", + param.GeneratedName.Wrap(), + param.RenderTagValueExpression() + ) ); } else { - writer.MethodCallOn(tagVariableName, "Add", param.GeneratedName.Wrap(), param.ParameterName); + writer.MethodCallOn( + tagVariableName, + "Add", + param.GeneratedName.Wrap(), + param.RenderTagValueExpression() + ); } } diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs index 515eb438..624f4b79 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs @@ -58,6 +58,11 @@ public static void GenerateImplementation(MeterOutputContext output, SourceProdu EmitFields(output, writer, context, readonlyFields: metricsOwnsConstructor); + UnionAccessorEmitter.EmitAccessors( + writer, + target.InstrumentationMethods.SelectMany(static m => m.Parameters).Select(static p => p.Union) + ); + if (metricsOwnsConstructor) EmitInlineConstructor(output, writer, context); else diff --git a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs new file mode 100644 index 00000000..3e201cf2 --- /dev/null +++ b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using Purview.Telemetry.SourceGenerator.Helpers; +using Purview.Telemetry.SourceGenerator.Records; + +namespace Purview.Telemetry.SourceGenerator.Emitters; + +/// +/// Emits the per-union accessors a generated class uses to read a union parameter's case value. +/// +/// +/// One accessor is emitted per distinct union type in the class, so the read logic exists once no matter how +/// many methods and targets use the union, and the emitted method is small enough for the JIT to inline into +/// every call site. Nothing here reflects over the union: every case is a compile-time TryGetValue +/// overload or the union's own Value property, so the generated code stays trim- and AOT-safe. +/// +static class UnionAccessorEmitter +{ + public static void EmitAccessors(CodeWriter writer, IEnumerable unions) + { + List distinct = []; + HashSet emitted = new(StringComparer.Ordinal); + + foreach (var union in unions) + { + if (union is null || !emitted.Add(union.AccessorName)) + continue; + + distinct.Add(union); + } + + if (distinct.Count == 0) + return; + + foreach (var union in distinct) + EmitAccessor(writer, union); + } + + static void EmitAccessor(CodeWriter writer, UnionTargetRecord union) + { + var useNullable = writer.IsNullableContextEnabled is null or true; + + writer.NewLine(); + + using ( + writer.MethodScope( + new MethodDeclarationOptions( + union.AccessorName, + TypeLibrary.System.Object.AsTypeReference().Nullable(writer), + TypeDeclarationAccessibility.Private + ) + { + IsStatic = true, + Attributes = [EmitterHelpers.AggressiveInliningAttribute()], + Parameters = [new ParameterDeclarationOptions("value", union.UnionType)], + } + ) + ) + { + var value = "value"; + + if (union.IsNullable) + { + // A nullable union is either a Nullable around a struct union or a nullable reference to a + // class union. Both mean "no value", which unwraps to null. + if (union.IsValueType) + { + writer.IfBlock( + "!value." + UnionHelpers.HasValueMemberName, + static body => body.Return(PropertyLibrary.System.NullKeyword) + ); + + writer + .NewLine() + .Write(union.UnderlyingUnionType.RenderFullNameForNullable(useNullable)) + .Write(" unionValue = value.GetValueOrDefault();") + .NewLine(); + + value = "unionValue"; + } + else + { + writer.IfBlock("value is null", static body => body.Return(PropertyLibrary.System.NullKeyword)); + + writer.NewLine(); + } + } + + if (!union.UsesNonBoxingAccess) + { + // A union declared with the `union` keyword stores object? and exposes the case value through + // Value, which is already boxed. Null contents mean the union holds no value. + writer.Return(value + "." + UnionHelpers.ValueMemberName); + return; + } + + writer.IfBlock( + value + "." + UnionHelpers.HasValueMemberName, + body => + { + for (var i = 0; i < union.CaseTypes.Count; i++) + { + var caseVariableName = "caseValue" + i.ToString(CultureInfo.InvariantCulture); + + // The out argument is the case type itself rather than `var`: a union declares one + // TryGetValue overload per case, so an inferred type would be ambiguous. + body.IfBlock( + value + + "." + + UnionHelpers.TryGetValueMemberName + + "(out " + + union.CaseTypes[i].RenderFullNameForNullable(useNullable) + + " " + + caseVariableName + + ")", + caseBody => caseBody.Return(caseVariableName) + ); + } + } + ); + + writer.NewLine().Return(PropertyLibrary.System.NullKeyword); + } + } +} diff --git a/src/src/SourceGenerator/Extensions/Purview/SourceGeneratorFramework/TypeLibraryExtensions.cs b/src/src/SourceGenerator/Extensions/Purview/SourceGeneratorFramework/TypeLibraryExtensions.cs index ad8992ab..9e880792 100644 --- a/src/src/SourceGenerator/Extensions/Purview/SourceGeneratorFramework/TypeLibraryExtensions.cs +++ b/src/src/SourceGenerator/Extensions/Purview/SourceGeneratorFramework/TypeLibraryExtensions.cs @@ -15,8 +15,6 @@ static class TypeLibraryExtensions TypeLibrary.Purview.Telemetry.CriticalAttribute ); - static readonly ImmutableDictionary InstrumentTypeMap = Create(); - static readonly ImmutableDictionary LogLevelMap = new Dictionary< TypeIdentity, LogLevelDetails @@ -36,23 +34,10 @@ static class TypeLibraryExtensions }, }.ToImmutableDictionary(); - static ImmutableDictionary Create() => - new Dictionary - { - { InstrumentTypes.Counter, TypeLibrary.System.Diagnostics.Metrics.Counter }, - { InstrumentTypes.UpDownCounter, TypeLibrary.System.Diagnostics.Metrics.UpDownCounter }, - { InstrumentTypes.Histogram, TypeLibrary.System.Diagnostics.Metrics.Histogram }, - { InstrumentTypes.ObservableCounter, TypeLibrary.System.Diagnostics.Metrics.ObservableCounter }, - { InstrumentTypes.ObservableGauge, TypeLibrary.System.Diagnostics.Metrics.ObservableGauge }, - { InstrumentTypes.ObservableUpDownCounter, TypeLibrary.System.Diagnostics.Metrics.ObservableUpDownCounter }, - }.ToImmutableDictionary(); - extension(TypeLibrary.Purview.Telemetry) { public static ImmutableArray LogAttributeTargets => LogAttributeTargets; public static ImmutableDictionary LogLevelMap => LogLevelMap; - - public static ImmutableDictionary InstrumentTypeMap => InstrumentTypeMap; } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs index 67177c12..2f84ce67 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs @@ -96,7 +96,12 @@ CancellationToken token //|| parseOptions.LanguageVersion >= LanguageVersion.CSharp8, SupportsIMeterFactory: parseOptions is null || !parseOptions.PreprocessorSymbolNames.Contains("NET48_OR_GREATER"), - UseActivitySourceFactory: SharedHelpers.UseActivitySourceFactory(compilation, token) + UseActivitySourceFactory: SharedHelpers.UseActivitySourceFactory(compilation, token), + SupportsGauge: SharedHelpers.IsTypeAvailable(compilation, TypeLibrary.System.Diagnostics.Metrics.Gauge), + SupportsInstrumentAdvice: SharedHelpers.IsTypeAvailable( + compilation, + TypeLibrary.System.Diagnostics.Metrics.InstrumentAdvice + ) ); } } diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs index ace7ca3e..d98dd73d 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs @@ -248,6 +248,22 @@ public static class Activities isBlocking: false ); + public static readonly ReportableDiagnostic ActivitySourceOptionsRequireFactory = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG3024", + title: "ActivitySourceOptions require UseActivitySourceFactory", + messageFormat: $"'{0}' is set, but UseActivitySourceFactory is not. Version and TelemetrySchemaUrl are " + + $"passed to {TypeLibrary.System.Diagnostics.ActivitySourceOptions.Name} for a source created by " + + $"{TypeLibrary.System.Diagnostics.ActivitySourceFactory.Name}, so a static " + + $"{TypeLibrary.System.Diagnostics.ActivitySource.Name} ignores them. Set " + + "UseActivitySourceFactory = true, or remove the option.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Activity.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + // The trailing '{1}' placeholder carries an optional hint supplied by the rule (for example when // the 'Name' was applied to a logging attribute); rules pass string.Empty when there is no // additional context. diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs index 147daf8c..ca07780a 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs @@ -141,9 +141,9 @@ public static class General new DiagnosticDescriptor( id: "TSG1011", title: "Unsupported target framework", - messageFormat: "Purview Telemetry Source Generator requires .NET 8.0 or higher, or .NET Framework 4.8 or higher. " + messageFormat: "Purview Telemetry Source Generator requires .NET 10.0 or higher, or .NET Framework 4.8 or higher. " + "The current target framework is not supported, so no telemetry is generated for it. " - + "Target net8.0 or later, or net48 or later; a library that also targets another framework " + + "Target net10.0 or later, or net48 or later; a library that also targets another framework " + "(such as netstandard2.0) must reference the generator only from its supported targets.", defaultSeverity: DiagnosticSeverity.Error, category: Categories.Usage, diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Metrics.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Metrics.cs index 2e934877..36cf4109 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Metrics.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Metrics.cs @@ -127,5 +127,41 @@ public static class Metrics ), isBlocking: false ); + + public static readonly ReportableDiagnostic GaugeNotAvailable = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG4010", + title: "Gauge is not available", + messageFormat: "A gauge needs System.Diagnostics.Metrics.Gauge, which .NET 9 and System.Diagnostics.DiagnosticSource 9 (or later) provide. The instrument is not generated, and the method throws NotSupportedException.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Metrics.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic InstrumentAdviceNotAvailable = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG4011", + title: "HistogramBucketBoundaries is not available", + messageFormat: "HistogramBucketBoundaries needs System.Diagnostics.Metrics.InstrumentAdvice, which .NET 9 and System.Diagnostics.DiagnosticSource 9 (or later) provide. The histogram is generated without bucket boundaries.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Metrics.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic InvalidHistogramBucketBoundaries = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG4012", + title: "HistogramBucketBoundaries is not a list of numbers", + messageFormat: "HistogramBucketBoundaries must be a comma-separated list of ascending, positive numbers, for example \"0.005, 0.01, 0.1, 1\". The value could not be parsed, so no bucket boundaries are generated.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Metrics.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); } } diff --git a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs index 52549fea..7d4a97f9 100644 --- a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs +++ b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs @@ -41,8 +41,7 @@ CancellationToken token continue; var instrumentAttribute = SharedHelpers.GetInstrumentAttribute(method, token); - var validAutoCounter = - instrumentAttribute?.InstrumentType is InstrumentTypes.Counter && instrumentAttribute.IsAutoIncrement; + var validAutoCounter = instrumentAttribute is CounterInstrumentAttributeRecord { AutoIncrement: true }; var parameters = GetInstrumentParameters( method, @@ -226,7 +225,8 @@ CancellationToken token GeneratedName: generatedName, ParamDestination: destination, SkipOnNullOrEmpty: PipelineHelpers.GetSkipOnNullOrEmptyValue(tagAttribute), - ExcludedTargets: excludeTargets?.ExcludedTargets ?? GenerationType.None + ExcludedTargets: excludeTargets?.ExcludedTargets ?? GenerationType.None, + Union: UnionHelpers.GetUnionTarget(parameter.Type, token) ) ); } diff --git a/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs b/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs index 531eeeeb..7db15c40 100644 --- a/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs +++ b/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs @@ -488,7 +488,8 @@ out bool hasError LogProperties: logProperties != null ? new([.. logProperties]) : [], ExpandEnumerableAttribute: expandEnumerableAttribute, ExcludedTargets: SharedHelpers.GetExcludeTargetsAttribute(parameter, token)?.ExcludedTargets - ?? GenerationType.None + ?? GenerationType.None, + Union: UnionHelpers.GetUnionTarget(parameter.Type, token) ) ); diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs index 6ec7c868..ec96f6b6 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs @@ -95,7 +95,7 @@ is not InterfaceDeclarationSyntax interfaceDeclaration ); } - static ImmutableArray BuildActivityMethods( + static ImmutableArray BuildActivityMethods( GenerationType generationType, ActivitySourceAttributeData activitySourceAttribute, ActivitySourceGenerationAttributeData? activitySourceGenerationAttribute, @@ -114,22 +114,21 @@ CancellationToken token var lowercaseBaggageAndTagKeys = activitySourceAttribute.LowercaseBaggageAndTagKeys; - List methodTargets = []; + List methodTargets = []; foreach (var method in GetAllInterfaceMethods(interfaceSymbol, token)) { token.ThrowIfCancellationRequested(); if (method.Arity > 0) { + // A generic method cannot be implemented for any target, so it is carried as a context method with + // an invalid generation state: the emitter reports it and generates a throw stub. methodTargets.Add( - new( + new ActivityContextMethod( MethodName: method.Name, ReturnType: TypeReference.Create(method.ReturnType), ActivityOrEventName: method.Name, HasActivityParameter: false, - ActivityAttribute: null, - EventAttribute: null, - MethodType: ActivityMethodType.Activity, Parameters: ImmutableArray.Empty, Baggage: ImmutableArray.Empty, Tags: ImmutableArray.Empty, @@ -182,23 +181,50 @@ out var eventAttribute GenerationType.Activities ); - methodTargets.Add( - new( + var returnType = TypeReference.Create(method.ReturnType); + var hasActivityParameter = parameters.Any(m => + m.ParameterType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity) + ); + + // The payload decides the case, so an activity attribute and an event attribute can never both be set, + // and a method that has neither is a context method rather than an activity with a null attribute. + ActivityMethodTarget methodTarget = + methodType == ActivityMethodType.Activity + ? new StartActivityMethod( + MethodName: method.Name, + ReturnType: returnType, + ActivityOrEventName: activityOrEventName!, + HasActivityParameter: hasActivityParameter, + Parameters: parameters, + Baggage: baggageParameters, + Tags: tagParameters, + TargetGenerationState: targetGenerationState, + ActivityAttribute: activityAttribute + ) + : methodType == ActivityMethodType.Event + ? new ActivityEventMethod( + MethodName: method.Name, + ReturnType: returnType, + ActivityOrEventName: activityOrEventName!, + HasActivityParameter: hasActivityParameter, + Parameters: parameters, + Baggage: baggageParameters, + Tags: tagParameters, + TargetGenerationState: targetGenerationState, + EventAttribute: eventAttribute + ) + : new ActivityContextMethod( MethodName: method.Name, - ReturnType: TypeReference.Create(method.ReturnType), + ReturnType: returnType, ActivityOrEventName: activityOrEventName!, - HasActivityParameter: parameters.Any(m => - m.ParameterType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity) - ), - ActivityAttribute: activityAttribute, - EventAttribute: eventAttribute, - MethodType: methodType, + HasActivityParameter: hasActivityParameter, Parameters: parameters, Baggage: baggageParameters, Tags: tagParameters, TargetGenerationState: targetGenerationState - ) - ); + ); + + methodTargets.Add(methodTarget); } return [.. methodTargets]; @@ -244,7 +270,8 @@ CancellationToken token ParamDestination: destination, SkipOnNullOrEmpty: GetSkipOnNullOrEmptyValue(tagOrBaggageAttribute), IsException: parameter.Type.IsExceptionType(), - ExcludedTargets: excludeTargets?.ExcludedTargets ?? GenerationType.None + ExcludedTargets: excludeTargets?.ExcludedTargets ?? GenerationType.None, + Union: UnionHelpers.GetUnionTarget(parameter.Type, token) ) ); } @@ -252,6 +279,17 @@ CancellationToken token return [.. parameterTargets]; } + /// + /// How a method participates in an activity interface. This is a construction-time decision: the closed + /// hierarchy carries it in the type, so nothing downstream switches on it. + /// + enum ActivityMethodType + { + Activity, + Event, + Context, + } + static (ActivityMethodType, bool) GetMethodType( IMethodSymbol method, CancellationToken token, diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs index c129e751..bb6a78d7 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs @@ -63,63 +63,105 @@ out var attributeData { token.ThrowIfCancellationRequested(); + // The attribute present decides the instrument, so the record this returns is already the right case. if (CounterAttributeData.TryFromAttributeData(symbol, out var counterAttribute)) - return ToRecord(counterAttribute, InstrumentTypes.Counter); + return new CounterInstrumentAttributeRecord( + NullIfWhitespace(counterAttribute.Name), + NullIfWhitespace(counterAttribute.Unit), + NullIfWhitespace(counterAttribute.Description), + counterAttribute.AutoIncrement + ); if (AutoCounterAttributeData.TryFromAttributeData(symbol, out var autoCounterAttribute)) - return ToRecord(autoCounterAttribute, InstrumentTypes.Counter); + return new CounterInstrumentAttributeRecord( + NullIfWhitespace(autoCounterAttribute.Name), + NullIfWhitespace(autoCounterAttribute.Unit), + NullIfWhitespace(autoCounterAttribute.Description), + AutoIncrement: true + ); if (UpDownCounterAttributeData.TryFromAttributeData(symbol, out var upDownCounterAttribute)) - return ToRecord(upDownCounterAttribute, InstrumentTypes.UpDownCounter); + return new UpDownCounterInstrumentAttributeRecord( + NullIfWhitespace(upDownCounterAttribute.Name), + NullIfWhitespace(upDownCounterAttribute.Unit), + NullIfWhitespace(upDownCounterAttribute.Description) + ); if (HistogramAttributeData.TryFromAttributeData(symbol, out var histogramAttribute)) - return ToRecord(histogramAttribute, InstrumentTypes.Histogram); + { + var boundaries = ParseHistogramBucketBoundaries(histogramAttribute.HistogramBucketBoundaries); + + return new HistogramInstrumentAttributeRecord( + NullIfWhitespace(histogramAttribute.Name), + NullIfWhitespace(histogramAttribute.Unit), + NullIfWhitespace(histogramAttribute.Description), + HistogramBucketBoundaries: boundaries, + InvalidHistogramBucketBoundaries: boundaries is null + && !string.IsNullOrWhiteSpace(histogramAttribute.HistogramBucketBoundaries) + ); + } + if (GaugeAttributeData.TryFromAttributeData(symbol, out var gaugeAttribute)) + return new GaugeInstrumentAttributeRecord( + NullIfWhitespace(gaugeAttribute.Name), + NullIfWhitespace(gaugeAttribute.Unit), + NullIfWhitespace(gaugeAttribute.Description) + ); if (ObservableCounterAttributeData.TryFromAttributeData(symbol, out var observableCounterAttribute)) - return ToRecord(observableCounterAttribute, InstrumentTypes.ObservableCounter); + return new ObservableCounterInstrumentAttributeRecord( + NullIfWhitespace(observableCounterAttribute.Name), + NullIfWhitespace(observableCounterAttribute.Unit), + NullIfWhitespace(observableCounterAttribute.Description), + observableCounterAttribute.ThrowOnAlreadyInitialized + ); if (ObservableUpDownCounterAttributeData.TryFromAttributeData(symbol, out var observableUpDownCounterAttribute)) - return ToRecord(observableUpDownCounterAttribute, InstrumentTypes.ObservableUpDownCounter); + return new ObservableUpDownCounterInstrumentAttributeRecord( + NullIfWhitespace(observableUpDownCounterAttribute.Name), + NullIfWhitespace(observableUpDownCounterAttribute.Unit), + NullIfWhitespace(observableUpDownCounterAttribute.Description), + observableUpDownCounterAttribute.ThrowOnAlreadyInitialized + ); if (ObservableGaugeAttributeData.TryFromAttributeData(symbol, out var observableGaugeAttribute)) - return ToRecord(observableGaugeAttribute, InstrumentTypes.ObservableGauge); + return new ObservableGaugeInstrumentAttributeRecord( + NullIfWhitespace(observableGaugeAttribute.Name), + NullIfWhitespace(observableGaugeAttribute.Unit), + NullIfWhitespace(observableGaugeAttribute.Description), + observableGaugeAttribute.ThrowOnAlreadyInitialized + ); // No matching instrument attribute found return null; } - static InstrumentAttributeRecord ToRecord( - string? name, - string? unit, - string? description, - bool autoIncrement, - bool throwOnAlreadyInitialized, - InstrumentTypes instrumentType - ) => - new( - Name: NullIfWhitespace(name), - Unit: NullIfWhitespace(unit), - Description: NullIfWhitespace(description), - AutoIncrement: autoIncrement, - ThrowOnAlreadyInitialized: throwOnAlreadyInitialized, - InstrumentType: instrumentType - ); - - static InstrumentAttributeRecord ToRecord(CounterAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, data.AutoIncrement, false, type); - - static InstrumentAttributeRecord ToRecord(AutoCounterAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, true, false, type); - - static InstrumentAttributeRecord ToRecord(UpDownCounterAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, false, false, type); + public static bool IsValidMeasurementValueType(ITypeSymbol type) => + PropertyLibrary.Metrics.ValidMeasurementKeywordTypes.Any(m => m.SpecialType == type.SpecialType); - static InstrumentAttributeRecord ToRecord(HistogramAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, false, false, type); + /// + /// Parses HistogramBucketBoundaries: a comma-separated list of invariant-culture, ascending, positive + /// numbers. A value that does not parse yields , so no advice is emitted and + /// TSG4012 reports it. + /// + internal static EquatableArray? ParseHistogramBucketBoundaries(string? boundaries) + { + if (string.IsNullOrWhiteSpace(boundaries)) + return null; - static InstrumentAttributeRecord ToRecord(ObservableCounterAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, false, data.ThrowOnAlreadyInitialized, type); + List values = []; + foreach (var part in boundaries!.Split(',')) + { + if ( + !double.TryParse( + part.Trim(), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var value + ) + || value <= 0 + ) + return null; - static InstrumentAttributeRecord ToRecord(ObservableUpDownCounterAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, false, data.ThrowOnAlreadyInitialized, type); + if (values.Count > 0 && value <= values[values.Count - 1]) + return null; - static InstrumentAttributeRecord ToRecord(ObservableGaugeAttributeData data, InstrumentTypes type) => - ToRecord(data.Name, data.Unit, data.Description, false, data.ThrowOnAlreadyInitialized, type); + values.Add(value); + } - public static bool IsValidMeasurementValueType(ITypeSymbol type) => - PropertyLibrary.Metrics.ValidMeasurementKeywordTypes.Any(m => m.SpecialType == type.SpecialType); + return values.Count == 0 ? null : new([.. values]); + } } diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.cs index e460fa3c..e3bb5e4a 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.cs @@ -30,6 +30,15 @@ public static bool ShouldEmit(GenerationType requestingType, GenerationType gene return generationType.HasFlag(requestingType); } + /// + /// Whether the compilation can resolve a type. Gate every emitted API on this rather than on the target + /// framework: an analyzer-only package reference never brings a type to the consumer, and the + /// System.Diagnostics.DiagnosticSource package carries .NET 9/10/11 types all the way down to + /// net462 and netstandard2.0. + /// + public static bool IsTypeAvailable(Compilation compilation, TypeIdentity identity) => + compilation.GetTypeByMetadataName(identity.MetadataFullName) is not null; + /// /// Returns the single target that "owns" shared emission (DI extension, class attributes, /// constructor, throw stubs) for a multi-target interface. Only one target emits these to diff --git a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Metrics.cs b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Metrics.cs index c0ef500b..e52dcdc4 100644 --- a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Metrics.cs +++ b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Metrics.cs @@ -23,6 +23,12 @@ partial class TypeLibraryGenerator [TypeRef(SystemDiagnosticsMetricsNamespace)] static readonly TypeIdentity Histogram = default; + [TypeRef(SystemDiagnosticsMetricsNamespace, 1)] + static readonly TypeIdentity Gauge = default; + + [TypeRef(SystemDiagnosticsMetricsNamespace, 1)] + static readonly TypeIdentity InstrumentAdvice = default; + [TypeRef(SystemDiagnosticsMetricsNamespace)] static readonly TypeIdentity ObservableCounter = default; diff --git a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs index 49df0298..14854153 100644 --- a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs +++ b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs @@ -89,6 +89,9 @@ partial class TypeLibraryGenerator [TypeRef(PurviewTelemetryNamespace, includeInGetTypes: true)] static readonly TypeIdentity HistogramAttribute = default; + [TypeRef(PurviewTelemetryNamespace, includeInGetTypes: true)] + static readonly TypeIdentity GaugeAttribute = default; + [TypeRef(PurviewTelemetryNamespace, includeInGetTypes: true)] static readonly TypeIdentity ObservableCounterAttribute = default; diff --git a/src/src/SourceGenerator/Helpers/UnionHelpers.cs b/src/src/SourceGenerator/Helpers/UnionHelpers.cs new file mode 100644 index 00000000..0277554e --- /dev/null +++ b/src/src/SourceGenerator/Helpers/UnionHelpers.cs @@ -0,0 +1,170 @@ +using Microsoft.CodeAnalysis; +using Purview.Telemetry.SourceGenerator.Records; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Recognises C# 15 union types (a type carrying [System.Runtime.CompilerServices.Union]) used as +/// telemetry parameters, and describes how the generated code should read a case value out of one. +/// +/// +/// Detection is symbol based, never target-framework based: a union is a plain type declaration, so the +/// compilation resolves it the same way on net48 as on net11.0. What differs is only whether it declares +/// the non-boxing access pattern, which decides how the generated accessor reads it. +/// +static class UnionHelpers +{ + public const string UnionAttributeName = "UnionAttribute"; + public const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; + public const string HasValueMemberName = "HasValue"; + public const string TryGetValueMemberName = "TryGetValue"; + public const string ValueMemberName = "Value"; + public const string UnionMembersInterfaceName = "IUnionMembers"; + public const string UnionMembersCreateMethodName = "Create"; + + /// + /// Builds the for a telemetry parameter, or when + /// the parameter's type is not a union. + /// + public static UnionTargetRecord? GetUnionTarget(ITypeSymbol parameterType, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + var isNullable = false; + var unionType = parameterType; + + if ( + parameterType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T + && parameterType is INamedTypeSymbol { TypeArguments.Length: 1 } nullable + ) + { + isNullable = true; + unionType = nullable.TypeArguments[0]; + } + else if (parameterType.NullableAnnotation == NullableAnnotation.Annotated) + { + isNullable = true; + } + + if (!IsUnion(unionType)) + return null; + + if (unionType is not INamedTypeSymbol namedUnionType) + return null; + + var caseTypes = GetCaseTypes(namedUnionType); + if (caseTypes.Count == 0) + return null; + + return new( + UnionType: TypeReference.Create(parameterType), + UnderlyingUnionType: TypeReference.Create(unionType), + CaseTypes: new([.. caseTypes.Select(static caseType => TypeReference.Create(caseType))]), + IsValueType: unionType.IsValueType, + IsNullable: isNullable, + UsesNonBoxingAccess: UsesNonBoxingAccess(unionType, caseTypes.Count) + ); + } + + /// + /// Whether the type is a union: it carries [System.Runtime.CompilerServices.Union]. + /// + public static bool IsUnion(ITypeSymbol type) + { + foreach (var attribute in type.GetAttributes()) + { + var attributeClass = attribute.AttributeClass; + if (attributeClass is null) + continue; + + if ( + attributeClass.Name == UnionAttributeName + && attributeClass.ContainingNamespace?.ToDisplayString() == CompilerServicesNamespace + ) + return true; + } + + return false; + } + + /// + /// The case types a union can hold: the single-parameter Create members of a nested + /// IUnionMembers provider when there is one, otherwise the union's own single-parameter + /// constructors, which is what the compiler generates for a union declaration. + /// + static List GetCaseTypes(INamedTypeSymbol unionType) + { + List caseTypes = []; + + var provider = unionType.GetTypeMembers(UnionMembersInterfaceName).FirstOrDefault(); + if (provider is not null) + { + foreach ( + var method in provider + .GetMembers(UnionMembersCreateMethodName) + .OfType() + .Where(static m => m.IsStatic && m.Parameters.Length == 1) + ) + caseTypes.Add(method.Parameters[0].Type); + } + + if (caseTypes.Count == 0) + { + foreach ( + var constructor in unionType.InstanceConstructors.Where(static c => + c.DeclaredAccessibility == Accessibility.Public && !c.IsStatic && c.Parameters.Length == 1 + ) + ) + caseTypes.Add(constructor.Parameters[0].Type); + } + + return caseTypes; + } + + /// + /// Whether the union declares the non-boxing access pattern: a bool HasValue property plus a + /// bool TryGetValue(out T) overload for every case type. When it does, the generated accessor + /// reads each case through TryGetValue and the compiler never boxes the union struct. + /// + static bool UsesNonBoxingAccess(ITypeSymbol unionType, int caseTypeCount) + { + // A union member provider declares the pattern on the nested interface the union implements. + var provider = unionType.GetTypeMembers(UnionMembersInterfaceName).FirstOrDefault(); + var searchTargets = provider is null ? [unionType] : new ITypeSymbol[] { unionType, provider }; + + var hasHasValue = false; + var tryGetValueOverloads = 0; + + foreach (var target in searchTargets) + { + foreach (var member in target.GetMembers()) + { + if ( + member is IPropertySymbol { IsStatic: false } property + && property.Name == HasValueMemberName + && property.Type.SpecialType == SpecialType.System_Boolean + ) + hasHasValue = true; + else if ( + member is IMethodSymbol { IsStatic: false, Parameters.Length: 1 } method + && method.Name == TryGetValueMemberName + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters[0].RefKind == RefKind.Out + ) + tryGetValueOverloads++; + } + } + + return hasHasValue && tryGetValueOverloads >= caseTypeCount; + } + + /// + /// The type a log state struct (or a LoggerMessage.Define generic argument) carries for a + /// parameter. A union is logged as its case value, so the state holds object?; every other + /// parameter keeps its declared type. + /// + public static string RenderLogStateType(LogParameterTarget parameter, bool useNullable) => + parameter.RequiresObjectState + ? (useNullable ? "object?" : "object") + : parameter.ParameterType.RenderFullNameForNullable(useNullable); +} diff --git a/src/src/SourceGenerator/Records/ActivityRecords.cs b/src/src/SourceGenerator/Records/ActivityRecords.cs index 6f5cafeb..06e79248 100644 --- a/src/src/SourceGenerator/Records/ActivityRecords.cs +++ b/src/src/SourceGenerator/Records/ActivityRecords.cs @@ -8,7 +8,7 @@ sealed record ActivitySourceTarget( TypeReference InterfaceType, ActivitySourceGenerationAttributeData? ActivitySourceGenerationAttribute, string? ActivitySourceName, - EquatableArray ActivityMethods, + EquatableArray ActivityMethods, ActivitySourceAttributeData ActivityTargetAttributeRecord ) { @@ -41,20 +41,110 @@ public string? FullNamespace FullNamespace is null ? ClassNameToGenerate : FullNamespace + ClassNameToGenerate; } -sealed record ActivityBasedGenerationTarget( +/// +/// A method declared on an activity interface. A method is exactly one of these: it starts an activity, records an +/// event, or returns a context, so the hierarchy is closed and a switch over it is exhaustive once the +/// three cases are handled. +/// +/// +/// The payload of each case lives on the case type rather than as a nullable member of the base, so a method that +/// starts an activity cannot be read for an event's attribute. The base carries only what every case needs. +/// +/// The interface method's name. +/// The method's declared return type. +/// The activity or event name, explicit or inferred. +/// Whether the method takes the Activity to record against. +/// Every parameter, in declaration order. +/// The parameters recorded as baggage. +/// The parameters recorded as tags. +/// The method's generation state. +/// The method's generic type parameters, when it is generic (and therefore invalid). +closed record ActivityMethodTarget( string MethodName, TypeReference ReturnType, string ActivityOrEventName, bool HasActivityParameter, + EquatableArray Parameters, + EquatableArray Baggage, + EquatableArray Tags, + TargetGeneration TargetGenerationState, + EquatableArray TypeParameters = default +); + +/// A method that starts an activity: [Activity], or inferred from an Activity return type. +sealed record StartActivityMethod( + string MethodName, + TypeReference ReturnType, + string ActivityOrEventName, + bool HasActivityParameter, + EquatableArray Parameters, + EquatableArray Baggage, + EquatableArray Tags, + TargetGeneration TargetGenerationState, ActivityAttributeData? ActivityAttribute, + EquatableArray TypeParameters = default +) + : ActivityMethodTarget( + MethodName, + ReturnType, + ActivityOrEventName, + HasActivityParameter, + Parameters, + Baggage, + Tags, + TargetGenerationState, + TypeParameters + ); + +/// A method that records an activity event: [Event], or inferred from an Event name or an +/// Activity first parameter. +sealed record ActivityEventMethod( + string MethodName, + TypeReference ReturnType, + string ActivityOrEventName, + bool HasActivityParameter, + EquatableArray Parameters, + EquatableArray Baggage, + EquatableArray Tags, + TargetGeneration TargetGenerationState, EventAttributeData? EventAttribute, - ActivityMethodType MethodType, + EquatableArray TypeParameters = default +) + : ActivityMethodTarget( + MethodName, + ReturnType, + ActivityOrEventName, + HasActivityParameter, + Parameters, + Baggage, + Tags, + TargetGenerationState, + TypeParameters + ); + +/// A method that returns a context: [Context], or inferred from a Context name. +sealed record ActivityContextMethod( + string MethodName, + TypeReference ReturnType, + string ActivityOrEventName, + bool HasActivityParameter, EquatableArray Parameters, EquatableArray Baggage, EquatableArray Tags, TargetGeneration TargetGenerationState, EquatableArray TypeParameters = default -); +) + : ActivityMethodTarget( + MethodName, + ReturnType, + ActivityOrEventName, + HasActivityParameter, + Parameters, + Baggage, + Tags, + TargetGenerationState, + TypeParameters + ); sealed record ActivityBasedParameterTarget( string ParameterName, @@ -63,9 +153,16 @@ sealed record ActivityBasedParameterTarget( ActivityParameterDestination ParamDestination, bool SkipOnNullOrEmpty, bool IsException, - GenerationType ExcludedTargets + GenerationType ExcludedTargets, + UnionTargetRecord? Union = null ) { + /// + /// The expression that yields the value recorded for this parameter: the parameter itself, or the + /// union's case value when the parameter is a union type. + /// + public string RenderValueExpression() => Union is null ? ParameterName : Union.RenderValueExpression(ParameterName); + /// /// True when the parameter opts out of the Activities target via /// [ExcludeTargets(Targets.Activities)]. The parameter stays part of the generated method @@ -87,10 +184,3 @@ enum ActivityParameterDestination Escape, StatusDescription, } - -enum ActivityMethodType -{ - Activity, - Event, - Context, -} diff --git a/src/src/SourceGenerator/Records/AttributeDataModels.cs b/src/src/SourceGenerator/Records/AttributeDataModels.cs index 2548f947..2682b1bc 100644 --- a/src/src/SourceGenerator/Records/AttributeDataModels.cs +++ b/src/src/SourceGenerator/Records/AttributeDataModels.cs @@ -28,7 +28,9 @@ readonly partial record struct ActivitySourceGenerationAttributeData( [Argument("generateDiagnosticsForMissingActivity")] [Property(DefaultValue = true)] bool GenerateDiagnosticsForMissingActivity, - [Property(DefaultValue = false)] bool UseActivitySourceFactory + [Property(DefaultValue = false)] bool UseActivitySourceFactory, + [Property] string? Version, + [Property] string? TelemetrySchemaUrl ); [Generate("Purview.Telemetry.ActivityAttribute")] @@ -231,6 +233,14 @@ readonly partial record struct UpDownCounterAttributeData( [Generate("Purview.Telemetry.HistogramAttribute")] readonly partial record struct HistogramAttributeData( + [Argument("name")] [Property] string? Name, + [Argument("unit")] [Property] string? Unit, + [Argument("description")] [Property] string? Description, + [Property] string? HistogramBucketBoundaries +); + +[Generate("Purview.Telemetry.GaugeAttribute")] +readonly partial record struct GaugeAttributeData( [Argument("name")] [Property] string? Name, [Argument("unit")] [Property] string? Unit, [Argument("description")] [Property] string? Description diff --git a/src/src/SourceGenerator/Records/LoggerRecords.cs b/src/src/SourceGenerator/Records/LoggerRecords.cs index f36c0dc1..011ce6f1 100644 --- a/src/src/SourceGenerator/Records/LoggerRecords.cs +++ b/src/src/SourceGenerator/Records/LoggerRecords.cs @@ -82,10 +82,24 @@ sealed record LogParameterTarget( EquatableArray? LogProperties, ExpandEnumerableAttributeData? ExpandEnumerableAttribute, GenerationType ExcludedTargets, - EquatableArray ReferencedHoles = default + EquatableArray ReferencedHoles = default, + UnionTargetRecord? Union = null ) { public bool UsedInTemplate => !ReferencedHoles.IsEmpty; + + /// + /// The expression that yields the value logged for this parameter: the parameter itself, or the union's + /// case value when the parameter is a union type. + /// + public string RenderValueExpression() => Union is null ? Name : Union.RenderValueExpression(Name); + + /// + /// The type the log state carries for this parameter. A union is logged as its case value, so the state + /// holds object? instead of the union struct — which is also what the logger would otherwise + /// format, because a union struct's ToString is not its contents. + /// + public bool RequiresObjectState => Union is not null; } readonly record struct LogPropertiesParameterDetails(string PropertyName, bool IsNullable); diff --git a/src/src/SourceGenerator/Records/MetricAttributeRecords.cs b/src/src/SourceGenerator/Records/MetricAttributeRecords.cs index 86ef5721..76e17793 100644 --- a/src/src/SourceGenerator/Records/MetricAttributeRecords.cs +++ b/src/src/SourceGenerator/Records/MetricAttributeRecords.cs @@ -1,19 +1,169 @@ namespace Purview.Telemetry.SourceGenerator.Records; -sealed record InstrumentAttributeRecord( +/// +/// The instrument a metrics method declares: exactly one of the eight System.Diagnostics.Metrics instruments, +/// so the hierarchy is closed and a switch over it is exhaustive. +/// +/// +/// Each case knows the Meter factory method that creates it, the instrument type it produces and how it +/// records. The emitters read those properties instead of building a factory name from an enum member's name, which +/// is what a rename used to break silently: the generator emitted CreateRenamedInstrument into the consumer's +/// build rather than failing here. +/// +/// The instrument name, or to use the method name. +/// The measurement unit. +/// The instrument description. +/// Whether the counter adds the value without a measurement parameter. +/// +/// Whether an observable instrument throws when it is initialised twice. +/// +closed record InstrumentAttributeRecord( string? Name, string? Unit, string? Description, bool AutoIncrement, - bool ThrowOnAlreadyInitialized, - InstrumentTypes InstrumentType + bool ThrowOnAlreadyInitialized ) { public bool IsAutoIncrement => AutoIncrement; - public bool IsObservable => - InstrumentType - is InstrumentTypes.ObservableCounter - or InstrumentTypes.ObservableGauge - or InstrumentTypes.ObservableUpDownCounter; + /// The Meter factory method that creates this instrument, for example CreateCounter. + public abstract string CreationMethodName { get; } + + /// The instrument type, for example System.Diagnostics.Metrics.Counter<T>. + public abstract TypeIdentity InstrumentType { get; } + + /// The instrument family name, as it appears in the API: Counter, ObservableGauge, ... + public abstract string TypeName { get; } + + /// Whether the instrument is registered once and observed by a callback rather than recorded. + public abstract bool IsObservable { get; } + + /// + /// Whether the instrument records with Record rather than Add: gauges and histograms record, + /// counters add. + /// + public abstract bool UsesRecordMethod { get; } +} + +/// A [Counter] instrument, including the auto-incrementing [AutoCounter]. +sealed record CounterInstrumentAttributeRecord(string? Name, string? Unit, string? Description, bool AutoIncrement) + : InstrumentAttributeRecord(Name, Unit, Description, AutoIncrement, false) +{ + public override string CreationMethodName => "CreateCounter"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.Counter; + + public override string TypeName => "Counter"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => false; +} + +/// An [UpDownCounter] instrument. +sealed record UpDownCounterInstrumentAttributeRecord(string? Name, string? Unit, string? Description) + : InstrumentAttributeRecord(Name, Unit, Description, false, false) +{ + public override string CreationMethodName => "CreateUpDownCounter"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.UpDownCounter; + + public override string TypeName => "UpDownCounter"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => false; +} + +/// A [Histogram] instrument, optionally with bucket boundaries. +sealed record HistogramInstrumentAttributeRecord( + string? Name, + string? Unit, + string? Description, + EquatableArray? HistogramBucketBoundaries = null, + bool InvalidHistogramBucketBoundaries = false +) : InstrumentAttributeRecord(Name, Unit, Description, false, false) +{ + public override string CreationMethodName => "CreateHistogram"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.Histogram; + + public override string TypeName => "Histogram"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => true; +} + +/// A [Gauge] instrument (.NET 9): records a value without accumulating it. +sealed record GaugeInstrumentAttributeRecord(string? Name, string? Unit, string? Description) + : InstrumentAttributeRecord(Name, Unit, Description, false, false) +{ + public override string CreationMethodName => "CreateGauge"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.Gauge; + + public override string TypeName => "Gauge"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => true; +} + +/// An [ObservableCounter] instrument. +sealed record ObservableCounterInstrumentAttributeRecord( + string? Name, + string? Unit, + string? Description, + bool ThrowOnAlreadyInitialized +) : InstrumentAttributeRecord(Name, Unit, Description, false, ThrowOnAlreadyInitialized) +{ + public override string CreationMethodName => "CreateObservableCounter"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.ObservableCounter; + + public override string TypeName => "ObservableCounter"; + + public override bool IsObservable => true; + + public override bool UsesRecordMethod => false; +} + +/// An [ObservableGauge] instrument. +sealed record ObservableGaugeInstrumentAttributeRecord( + string? Name, + string? Unit, + string? Description, + bool ThrowOnAlreadyInitialized +) : InstrumentAttributeRecord(Name, Unit, Description, false, ThrowOnAlreadyInitialized) +{ + public override string CreationMethodName => "CreateObservableGauge"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.ObservableGauge; + + public override string TypeName => "ObservableGauge"; + + public override bool IsObservable => true; + + public override bool UsesRecordMethod => false; +} + +/// An [ObservableUpDownCounter] instrument. +sealed record ObservableUpDownCounterInstrumentAttributeRecord( + string? Name, + string? Unit, + string? Description, + bool ThrowOnAlreadyInitialized +) : InstrumentAttributeRecord(Name, Unit, Description, false, ThrowOnAlreadyInitialized) +{ + public override string CreationMethodName => "CreateObservableUpDownCounter"; + + public override TypeIdentity InstrumentType => TypeLibrary.System.Diagnostics.Metrics.ObservableUpDownCounter; + + public override string TypeName => "ObservableUpDownCounter"; + + public override bool IsObservable => true; + + public override bool UsesRecordMethod => false; } diff --git a/src/src/SourceGenerator/Records/MetricRecords.cs b/src/src/SourceGenerator/Records/MetricRecords.cs index 37d0f04e..9d81d286 100644 --- a/src/src/SourceGenerator/Records/MetricRecords.cs +++ b/src/src/SourceGenerator/Records/MetricRecords.cs @@ -70,19 +70,17 @@ sealed record InstrumentParameterTarget( string GeneratedName, InstrumentParameterDestination ParamDestination, bool SkipOnNullOrEmpty, - GenerationType ExcludedTargets -); - -enum InstrumentTypes + GenerationType ExcludedTargets, + UnionTargetRecord? Union = null +) { - Counter, - UpDownCounter, - Histogram, - - ObservableCounter, - ObservableGauge, - ObservableUpDownCounter, -} + /// + /// The expression that yields the tag value recorded for this parameter: the parameter itself, or the + /// union's case value when the parameter is a union type. + /// + public string RenderTagValueExpression() => + Union is null ? ParameterName : Union.RenderValueExpression(ParameterName); +}; enum InstrumentParameterDestination { diff --git a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs index a46d5c86..ccbcc203 100644 --- a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs +++ b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs @@ -19,8 +19,19 @@ namespace Purview.Telemetry.SourceGenerator.Records; /// here, rather than on the activity target, because the constructor that receives the factory is emitted by /// whichever target owns the constructor. /// +/// +/// Whether the compilation can resolve System.Diagnostics.Metrics.Gauge<T> (.NET 9, or +/// System.Diagnostics.DiagnosticSource 9+ on any target). Gated on the symbol, not the target framework, because an +/// analyzer-only package reference does not bring the type to a consumer that pins an older DiagnosticSource. +/// +/// +/// Whether the compilation can resolve System.Diagnostics.Metrics.InstrumentAdvice<T> (.NET 9, or +/// System.Diagnostics.DiagnosticSource 9+). Gates the histogram bucket boundaries. +/// sealed record TelemetryCapabilities( //bool SupportsNullableAnnotations, bool SupportsIMeterFactory, - bool UseActivitySourceFactory = false + bool UseActivitySourceFactory = false, + bool SupportsGauge = false, + bool SupportsInstrumentAdvice = false ) : IGenerationCapabilities; diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs index 37ad6025..dfa848d6 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs @@ -34,6 +34,31 @@ CancellationToken token ) ); + // TSG3024: the options are only consumed by a factory-created source, so setting them on an assembly + // that kept the static ActivitySource would silently do nothing. + if (target.ActivitySourceGenerationAttribute?.UseActivitySourceFactory != true) + { + if (!string.IsNullOrWhiteSpace(target.ActivitySourceGenerationAttribute?.Version)) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Activities.ActivitySourceOptionsRequireFactory.Descriptor, + isBlocking: false, + interfaceSymbol, + "Version" + ) + ); + + if (!string.IsNullOrWhiteSpace(target.ActivitySourceGenerationAttribute?.TelemetrySchemaUrl)) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Activities.ActivitySourceOptionsRequireFactory.Descriptor, + isBlocking: false, + interfaceSymbol, + "TelemetrySchemaUrl" + ) + ); + } + // TSG3001: no activity source specified. if (string.IsNullOrWhiteSpace(target.ActivitySourceName)) diagnostics.Add( @@ -48,8 +73,8 @@ CancellationToken token // TSG3012: event/context methods exist but no Activity method. if ( - !validMethods.Any(static m => m.MethodType == ActivityMethodType.Activity) - && validMethods.Any(static m => m.MethodType != ActivityMethodType.Activity) + !validMethods.Any(static m => m is StartActivityMethod) + && validMethods.Any(static m => m is not StartActivityMethod) ) diagnostics.Add( ReportableDiagnostic.Create( @@ -80,7 +105,7 @@ CancellationToken token } static void ApplyActivityMethodRules( - ActivityBasedGenerationTarget method, + ActivityMethodTarget method, IMethodSymbol methodSymbol, bool generateDiagnosticsForMissingActivity, ImmutableArray.Builder diagnostics, @@ -88,14 +113,14 @@ CancellationToken token ) { // TSG3002: invalid return type. Events must return void; activity/context methods may return void or Activity. - var isEvent = method.MethodType == ActivityMethodType.Event; + var isEvent = method is ActivityEventMethod; var isValidReturnType = isEvent ? method.ReturnType.Identity.SpecialType == SpecialType.System_Void : method.ReturnType.Identity.SpecialType == SpecialType.System_Void || method.ReturnType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity); // TSG3013/TSG3022: activity methods should return a nullable Activity. - if (method.MethodType == ActivityMethodType.Activity && isValidReturnType) + if (method is StartActivityMethod && isValidReturnType) { var returnsActivity = method.ReturnType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity); if (!returnsActivity) @@ -127,7 +152,7 @@ CancellationToken token // TSG3014/TSG3015: best-practice diagnostics for missing/misplaced Activity parameters, // opt-in via [ActivitySourceGeneration(GenerateDiagnosticsForMissingActivity = ...)]. - if (generateDiagnosticsForMissingActivity && method.MethodType != ActivityMethodType.Activity) + if (generateDiagnosticsForMissingActivity && method is not StartActivityMethod) { if (!method.HasActivityParameter) diagnostics.Add( @@ -158,10 +183,10 @@ CancellationToken token // applies when the exception is recorded using the OpenTelemetry exception rules: with // UseRecordExceptionRules disabled the exception is emitted as a plain tag, a baggage exception is // set as baggage, and a parameter excluded from the Activities target is not applied at all. - if (method.MethodType == ActivityMethodType.Event) + if (method is ActivityEventMethod activityEventMethod) { var useRecordExceptionRules = - method.EventAttribute?.UseRecordExceptionRules + activityEventMethod.EventAttribute?.UseRecordExceptionRules ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; var recordsException = @@ -195,7 +220,7 @@ CancellationToken token } static void ApplyBaggageRules( - ActivityBasedGenerationTarget method, + ActivityMethodTarget method, IMethodSymbol methodSymbol, ImmutableArray.Builder diagnostics, CancellationToken token @@ -219,7 +244,7 @@ CancellationToken token } static void ApplyDuplicateReservedRules( - ActivityBasedGenerationTarget method, + ActivityMethodTarget method, IMethodSymbol methodSymbol, ImmutableArray.Builder diagnostics ) @@ -250,7 +275,7 @@ ImmutableArray.Builder diagnostics } static void ApplyReservedParameterRules( - ActivityBasedGenerationTarget method, + ActivityMethodTarget method, IMethodSymbol methodSymbol, ImmutableArray.Builder diagnostics, CancellationToken token @@ -270,7 +295,7 @@ CancellationToken token #pragma warning disable IDE0010 // Add missing cases switch (parameter.ParamDestination) { - case ActivityParameterDestination.Activity when method.MethodType == ActivityMethodType.Activity: + case ActivityParameterDestination.Activity when method is StartActivityMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.ActivityParameterNotAllowed.Descriptor, @@ -280,7 +305,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.Timestamp when method.MethodType != ActivityMethodType.Event: + case ActivityParameterDestination.Timestamp when method is not ActivityEventMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.TimestampParameterNotAllowed.Descriptor, @@ -290,7 +315,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.StartTime when method.MethodType != ActivityMethodType.Activity: + case ActivityParameterDestination.StartTime when method is not StartActivityMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.StartTimeParameterNotAllowed.Descriptor, @@ -300,8 +325,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.ParentContextOrId - when method.MethodType != ActivityMethodType.Activity: + case ActivityParameterDestination.ParentContextOrId when method is not StartActivityMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.ParentContextOrIdParameterNotAllowed.Descriptor, @@ -311,7 +335,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.LinksEnumerable when method.MethodType != ActivityMethodType.Activity: + case ActivityParameterDestination.LinksEnumerable when method is not StartActivityMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.LinksParameterNotAllowed.Descriptor, @@ -321,7 +345,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.TagsEnumerable when method.MethodType == ActivityMethodType.Context: + case ActivityParameterDestination.TagsEnumerable when method is ActivityContextMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.TagsParameterNotAllowed.Descriptor, @@ -341,7 +365,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.Escape when method.MethodType != ActivityMethodType.Event: + case ActivityParameterDestination.Escape when method is not ActivityEventMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.EscapedParameterIsOnlyValidOnEvent.Descriptor, @@ -361,7 +385,7 @@ CancellationToken token ) ); break; - case ActivityParameterDestination.StatusDescription when method.MethodType != ActivityMethodType.Event: + case ActivityParameterDestination.StatusDescription when method is not ActivityEventMethod: diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.StatusDescriptionParameterInvalidType.Descriptor, diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs b/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs index 98fb80e5..b71fd734 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Purview.Telemetry.SourceGenerator.Helpers; using Purview.Telemetry.SourceGenerator.Records; namespace Purview.Telemetry.SourceGenerator; @@ -13,11 +14,18 @@ static partial class TelemetryRules public static ImmutableArray GetMeterDiagnostics( MeterTarget target, INamedTypeSymbol interfaceSymbol, + Compilation compilation, CancellationToken token ) { var diagnostics = ImmutableArray.CreateBuilder(); + var supportsGauge = SharedHelpers.IsTypeAvailable(compilation, TypeLibrary.System.Diagnostics.Metrics.Gauge); + var supportsInstrumentAdvice = SharedHelpers.IsTypeAvailable( + compilation, + TypeLibrary.System.Diagnostics.Metrics.InstrumentAdvice + ); + foreach (var instrument in target.InstrumentationMethods) { token.ThrowIfCancellationRequested(); @@ -29,6 +37,46 @@ CancellationToken token if (methodSymbol is null) continue; + // TSG4010: a gauge needs System.Diagnostics.Metrics.Gauge, which an older analyzer-only + // System.Diagnostics.DiagnosticSource does not carry. + if (instrument.InstrumentAttribute is GaugeInstrumentAttributeRecord && !supportsGauge) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Metrics.GaugeNotAvailable.Descriptor, + isBlocking: false, + methodSymbol + ) + ); + + // TSG4011: histogram bucket boundaries need InstrumentAdvice. + if ( + instrument.InstrumentAttribute + is HistogramInstrumentAttributeRecord { HistogramBucketBoundaries: { Count: > 0 } } + && !supportsInstrumentAdvice + ) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Metrics.InstrumentAdviceNotAvailable.Descriptor, + isBlocking: false, + methodSymbol + ) + ); + + // TSG4012: the boundaries were supplied but could not be parsed. + if ( + instrument.InstrumentAttribute is HistogramInstrumentAttributeRecord + { + InvalidHistogramBucketBoundaries: true, + } + ) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Metrics.InvalidHistogramBucketBoundaries.Descriptor, + isBlocking: false, + methodSymbol + ) + ); + ApplyInstrumentRules(instrument, methodSymbol, diagnostics); } @@ -147,7 +195,7 @@ ImmutableArray.Builder diagnostics } // TSG4009: instrument name matches the instrument type name. - var instrumentTypeName = GetInstrumentTypeName(instrument.InstrumentAttribute.InstrumentType); + var instrumentTypeName = instrument.InstrumentAttribute.TypeName; if ( !string.IsNullOrWhiteSpace(instrument.MetricName) && string.Equals(instrument.MetricName, instrumentTypeName, StringComparison.OrdinalIgnoreCase) @@ -161,16 +209,4 @@ ImmutableArray.Builder diagnostics ) ); } - - static string GetInstrumentTypeName(InstrumentTypes type) => - type switch - { - InstrumentTypes.Counter => "Counter", - InstrumentTypes.UpDownCounter => "UpDownCounter", - InstrumentTypes.Histogram => "Histogram", - InstrumentTypes.ObservableCounter => "ObservableCounter", - InstrumentTypes.ObservableGauge => "ObservableGauge", - InstrumentTypes.ObservableUpDownCounter => "ObservableUpDownCounter", - _ => type.ToString(), - }; } diff --git a/src/src/SourceGenerator/Records/TelemetryRules.cs b/src/src/SourceGenerator/Records/TelemetryRules.cs index 8fe31e97..ca0aaa6e 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.cs @@ -58,6 +58,7 @@ public static ImmutableArray GetAllSupportedDescriptors() DiagnosticLibrary.Activities.ExceptionEventNotStandardName.Descriptor, DiagnosticLibrary.Activities.ActivityReturnTypeShouldBeNullable.Descriptor, DiagnosticLibrary.Activities.ActivitySourceFactoryNotAvailable.Descriptor, + DiagnosticLibrary.Activities.ActivitySourceOptionsRequireFactory.Descriptor, DiagnosticLibrary.Metrics.NoInstrumentDefined.Descriptor, DiagnosticLibrary.Metrics.DoesNotReturnVoid.Descriptor, DiagnosticLibrary.Metrics.AutoIncrementCountAndMeasurementParam.Descriptor, @@ -68,6 +69,9 @@ public static ImmutableArray GetAllSupportedDescriptors() DiagnosticLibrary.Metrics.ObservableCannotReturnBool.Descriptor, DiagnosticLibrary.Metrics.AutoCounterMustReturnVoid.Descriptor, DiagnosticLibrary.Metrics.InstrumentNameMatchesType.Descriptor, + DiagnosticLibrary.Metrics.GaugeNotAvailable.Descriptor, + DiagnosticLibrary.Metrics.InstrumentAdviceNotAvailable.Descriptor, + DiagnosticLibrary.Metrics.InvalidHistogramBucketBoundaries.Descriptor, ]; static IMethodSymbol? FindMethod(INamedTypeSymbol interfaceSymbol, string methodName) => @@ -84,7 +88,7 @@ static Location GetParameterLocation(IMethodSymbol method, string? parameterName ?? Location.None; /// - /// Determines whether the target framework of the compilation is supported (net8.0+/net48+). + /// Determines whether the target framework of the compilation is supported (net10.0+/net48+). /// A compilation with no target-framework symbols is treated as supported. /// public static bool IsUnsupportedTargetFramework(Compilation compilation) @@ -94,7 +98,7 @@ public static bool IsUnsupportedTargetFramework(Compilation compilation) if (tree.Options is CSharpParseOptions options) { var symbols = options.PreprocessorSymbolNames; - return !symbols.Contains("NET8_0_OR_GREATER") + return !symbols.Contains("NET10_0_OR_GREATER") && !symbols.Contains("NET48_OR_GREATER") && symbols.Any(static s => s.StartsWith("NET", StringComparison.Ordinal)); } diff --git a/src/src/SourceGenerator/Records/UnionRecords.cs b/src/src/SourceGenerator/Records/UnionRecords.cs new file mode 100644 index 00000000..c6f81936 --- /dev/null +++ b/src/src/SourceGenerator/Records/UnionRecords.cs @@ -0,0 +1,55 @@ +namespace Purview.Telemetry.SourceGenerator.Records; + +/// +/// A union-typed telemetry parameter: the union's declared type, the case types it can hold, and how the +/// generated code reads a case value back out of it. +/// +/// +/// A union is recorded as its case value rather than as the union struct: SetTag, a log value or a +/// metric tag has no meaning for a wrapper, and a default union holds no case at all. When the union +/// declares the non-boxing access pattern (a bool HasValue property plus a TryGetValue(out T) +/// overload per case type) the generated accessor calls those, so the union struct itself is never boxed +/// to read its contents. A union declared with the union keyword has no TryGetValue — the +/// language-generated form is always a struct holding object? — so its contents come from +/// Value, which is already the boxed case value. +/// +/// The parameter's declared type, nullability included. +/// The union type itself, without any nullable wrapper or annotation. +/// The case types, in declaration order. +/// Whether the union is a struct (a compiler-generated union always is). +/// Whether the parameter is a nullable union or a Nullable<T> of one. +/// Whether the union declares the non-boxing access pattern. +sealed record UnionTargetRecord( + TypeReference UnionType, + TypeReference UnderlyingUnionType, + EquatableArray CaseTypes, + bool IsValueType, + bool IsNullable, + bool UsesNonBoxingAccess +) +{ + /// + /// The name of the generated static object? accessor that unwraps this union. It is derived from + /// the union's metadata name, so two unions with the same short name in different namespaces do not + /// collide, and it is stable across runs so the generated output stays deterministic. + /// + public string AccessorName { get; } = "UnwrapUnion_" + ToIdentifier(UnderlyingUnionType.Identity.MetadataFullName); + + /// + /// The expression that reads the case value out of a parameter of this union: a call to the generated + /// accessor, which returns the case value or when the parameter holds no value. + /// + public string RenderValueExpression(string parameterName) => AccessorName + "(" + parameterName + ")"; + + static string ToIdentifier(string metadataName) + { + // MetadataFullName renders generic arity as `1 and nested types with '+', neither of which is a + // legal identifier character. + System.Text.StringBuilder builder = new(metadataName.Length); + + foreach (var c in metadataName) + builder.Append(char.IsLetterOrDigit(c) ? c : '_'); + + return builder.ToString(); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs index b35eab2c..546d96a0 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs @@ -14,9 +14,9 @@ public class UnsupportedTargetFrameworkTests { /// The exact TSG1011 message. It names the supported targets and offers no opt-out. const string ExpectedMessage = - "Purview Telemetry Source Generator requires .NET 8.0 or higher, or .NET Framework 4.8 or higher. " + "Purview Telemetry Source Generator requires .NET 10.0 or higher, or .NET Framework 4.8 or higher. " + "The current target framework is not supported, so no telemetry is generated for it. " - + "Target net8.0 or later, or net48 or later; a library that also targets another framework " + + "Target net10.0 or later, or net48 or later; a library that also targets another framework " + "(such as netstandard2.0) must reference the generator only from its supported targets."; const string TelemetryInterface = """ @@ -67,7 +67,7 @@ CancellationToken cancellationToken } [Test] - [Arguments("NET;NET8_0;NET8_0_OR_GREATER;NETCOREAPP")] + [Arguments("NET;NET10_0;NET10_0_OR_GREATER;NETCOREAPP")] [Arguments("NETFRAMEWORK;NET48;NET48_OR_GREATER")] public async Task Analyze_GivenSupportedTargetFramework_DoesNotRaiseTSG1011( string preprocessorSymbols, diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceOptions.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceOptions.cs new file mode 100644 index 00000000..5720de24 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceOptions.cs @@ -0,0 +1,126 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Activities; + +/// +/// Version and TelemetrySchemaUrl on [assembly: ActivitySourceGeneration(...)], which are +/// passed to System.Diagnostics.ActivitySourceOptions for a factory-created source. +/// +partial class TelemetrySourceGeneratorActivityTests +{ + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenVersionAndTelemetrySchemaUrl_PassesThemToTheFactoryOptions( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + [assembly: ActivitySourceGeneration( + "testing-activity-source", + UseActivitySourceFactory = true, + Version = "1.2.3", + TelemetrySchemaUrl = "https://purview.dev/schemas/telemetry/1.0")] + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + + await Assert + .That(generated) + .ContainsGeneratedCode( + """ + _activitySource = activitySourceFactory.Create(new global::System.Diagnostics.ActivitySourceOptions("testing-activity-source") + { + Version = "1.2.3", + TelemetrySchemaUrl = "https://purview.dev/schemas/telemetry/1.0", + }); + """ + ) + .Because("both options belong to the factory's ActivitySourceOptions, not to the source name"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3023"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3024"); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenVersionOnly_PassesJustTheVersion(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Diagnostics; + + [assembly: ActivitySourceGeneration("testing-activity-source", UseActivitySourceFactory = true, Version = "2.0.0")] + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + + await Assert.That(generated).ContainsGeneratedCode("Version = \"2.0.0\","); + await Assert.That(generated).DoesNotContain("TelemetrySchemaUrl ="); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenOptionsWithoutTheFactory_ReportsTSG3024AndKeepsTheStaticSource( + CancellationToken cancellationToken + ) + { + // Arrange: without UseActivitySourceFactory there is no ActivitySourceOptions to pass them to. + const string source = """ + using System.Diagnostics; + + [assembly: ActivitySourceGeneration( + "testing-activity-source", + Version = "1.2.3", + TelemetrySchemaUrl = "https://purview.dev/schemas/telemetry/1.0")] + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + + await Assert + .That(generated) + .Contains("static readonly global::System.Diagnostics.ActivitySource") + .Because("the static source is kept when the factory is not opted into"); + await Assert.That(generationResult).HasDiagnostic("TSG3024"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GaugeInstrument.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GaugeInstrument.cs new file mode 100644 index 00000000..17eeb792 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GaugeInstrument.cs @@ -0,0 +1,120 @@ +namespace Purview.Telemetry.SourceGenerator.Metrics; + +/// +/// The synchronous Gauge<T> instrument (.NET 9) and its InstrumentAdvice<T> histogram +/// bucket boundaries. +/// +partial class TelemetrySourceGeneratorMetricsTests +{ + [Test] + public async Task Generate_GivenGauge_GeneratesAGaugeInstrument(CancellationToken cancellationToken) + { + // Arrange + const string basicMetric = """ + + using System.Diagnostics.Metrics; + using System.Collections.Generic; + + namespace Testing; + + [Meter("testing-meter")] + public interface ITestMetrics + { + [Gauge("queue-depth", Unit = "items", Description = "The number of queued items")] + void QueueDepth(int value, [Tag] string queue); + + [Gauge] + void Ratio(double value); + } + + """; + + // Act + using var generationResult = await GenerateAsync(basicMetric, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("TestMetricsCore.Metric.g.cs"); + + await Assert + .That(generated) + .ContainsGeneratedCode("_queueDepthInstrument = _meter.CreateGauge(name:") + .Because("a gauge is created with CreateGauge"); + await Assert + .That(generated) + .ContainsGeneratedCode("_queueDepthInstrument.Record(value") + .Because("a gauge records a value, it does not add to one"); + await Assert.That(generated).ContainsGeneratedCode("_ratioInstrument = _meter.CreateGauge("); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG4010"); + } + + [Test] + public async Task Generate_GivenHistogramBucketBoundaries_GeneratesInstrumentAdvice( + CancellationToken cancellationToken + ) + { + // Arrange + const string basicMetric = """ + + using System.Diagnostics.Metrics; + + namespace Testing; + + [Meter("testing-meter")] + public interface ITestMetrics + { + [Histogram("request-duration", HistogramBucketBoundaries = "0.005, 0.01, 0.1, 1")] + void RequestDuration(double value); + } + + """; + + // Act + using var generationResult = await GenerateAsync(basicMetric, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("TestMetricsCore.Metric.g.cs"); + + await Assert + .That(generated) + .ContainsGeneratedCode( + "advice: new global::System.Diagnostics.Metrics.InstrumentAdvice { HistogramBucketBoundaries = new double[] { 0.005d, 0.01d, 0.1d, 1d } }" + ); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG4011"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG4012"); + } + + [Test] + public async Task Generate_GivenUnparsableHistogramBucketBoundaries_ReportsTSG4012( + CancellationToken cancellationToken + ) + { + // Arrange: not ascending, so no advice is emitted. + const string basicMetric = """ + + using System.Diagnostics.Metrics; + + namespace Testing; + + [Meter("testing-meter")] + public interface ITestMetrics + { + [Histogram("request-duration", HistogramBucketBoundaries = "1, 0.5")] + void RequestDuration(double value); + } + + """; + + // Act + using var generationResult = await GenerateAsync(basicMetric, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestMetricsCore.Metric.g.cs"); + + await Assert.That(generated).DoesNotContain("InstrumentAdvice"); + await Assert.That(generationResult).HasDiagnostic("TSG4012"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.cs new file mode 100644 index 00000000..6c366392 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorUnionTests.cs @@ -0,0 +1,269 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Generators; + +/// +/// C# 15 union parameters. A union is never recorded as itself: the generated code unwraps it to its case +/// value, through the non-boxing TryGetValue/HasValue pattern when the union declares one. +/// +/// +/// The unions in these sources are hand-written rather than declared with the union keyword, so the +/// tests stay valid on every leg (net48 and net10.0 have no UnionAttribute). Detection is by the +/// attribute's name, so a compiler-generated union and a hand-written one take the same path; what differs +/// is only whether the non-boxing access pattern is present. +/// +[SkipOnNetFramework] // the test sources need record types and nullable annotations, whose support types +// (.NET Framework has neither IsExternalInit nor UnionAttribute) are not in the box. +public partial class TelemetrySourceGeneratorUnionTests : IncrementalSourceGeneratorTestBase +{ + /// A union that declares the non-boxing access pattern. + const string NonBoxingUnion = """ + + using System; + + namespace System.Runtime.CompilerServices + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class UnionAttribute : Attribute { } + } + + namespace Testing + { + public record class Cat(string Name); + public record class Dog(string Name); + + [System.Runtime.CompilerServices.Union] + public struct Pet + { + readonly object? _value; + + public Pet(Cat value) => _value = value; + + public Pet(Dog value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Cat value) + { + if (_value is Cat cat) + { + value = cat; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out Dog value) + { + if (_value is Dog dog) + { + value = dog; + return true; + } + + value = null!; + return false; + } + } + } + + """; + + [Test] + public async Task Generate_GivenUnionTagAndBaggageParameters_UnwrapsEveryCase(CancellationToken cancellationToken) + { + // Arrange + var source = + NonBoxingUnion + + """ + + namespace Testing + { + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet, [Baggage] Pet baggage); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Cat caseValue0)"); + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Dog caseValue1)"); + await Assert.That(generated).Contains("SetTag(\"pet\", UnwrapUnion_Testing_Pet(pet))"); + await Assert.That(generated).Contains("SetBaggage(\"baggage\", UnwrapUnion_Testing_Pet(baggage)?.ToString())"); + } + + [Test] + public async Task Generate_GivenUnionTagParameter_ReadsTheCaseWithoutBoxingTheUnion( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + NonBoxingUnion + + """ + + namespace Testing + { + [Meter("testing-meter")] + public interface ITestMetrics + { + [Counter] + void Increment(int value, [Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Metric.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains(", UnwrapUnion_Testing_Pet(pet))"); + } + + [Test] + public async Task Generate_GivenUnionLogParameterWithV2Generation_LogsTheCaseValue( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + NonBoxingUnion + + """ + + namespace Testing + { + [Logger(GenerationMode = LoggerGenerationMode.V2)] + public interface ITestLogger + { + [Info("Pet {Pet}")] + void LogPet(Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Logging.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("public LogPet_LogState(object? pet)"); + await Assert.That(generated).Contains("new LogPet_LogState(UnwrapUnion_Testing_Pet(pet))"); + await Assert.That(generated).Contains("object? _Pet"); + } + + [Test] + public async Task Generate_GivenUnionLogParameterWithV1Generation_LogsTheCaseValue( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + NonBoxingUnion + + """ + + namespace Testing + { + [Logger(GenerationMode = LoggerGenerationMode.V1)] + public interface ITestLogger + { + [Info("Pet {Pet}")] + void LogPet(Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Logging.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("Define"); + } + + [Test] + public async Task Generate_GivenUnionWithoutNonBoxingAccess_FallsBackToTheValueProperty( + CancellationToken cancellationToken + ) + { + // Arrange: a union declared with the `union` keyword has no TryGetValue, only Value. + const string source = """ + + using System; + + namespace System.Runtime.CompilerServices + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] + public sealed class UnionAttribute : Attribute { } + } + + namespace Testing + { + public record class Cat(string Name); + public record class Dog(string Name); + + [System.Runtime.CompilerServices.Union] + public struct Pet + { + readonly object? _value; + + public Pet(Cat value) => _value = value; + + public Pet(Dog value) => _value = value; + + public object? Value => _value; + } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("return value.Value;"); + await Assert.That(generated).Contains("SetTag(\"pet\", UnwrapUnion_Testing_Pet(pet))"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj index 8936a7df..7396b493 100644 --- a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj +++ b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj @@ -6,8 +6,8 @@ - net8.0;net9.0;net10.0;net11.0; + exercises the UseActivitySourceFactory generation; the net10.0 leg exercises its fallback. --> + net10.0;net11.0; net48;$(TargetFrameworks) @@ -31,9 +31,10 @@ warning is suppressed. --> true - - + + From 2f1c731247863f7330787df49155057c8cd31acd Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 14:05:30 +0000 Subject: [PATCH 2/8] feat: updated union support with tests --- .../purview-architecture-testing/SKILL.md | 5 +- .agents/skills/purview-architecture/SKILL.md | 3 +- .../references/contract-strategy.md | 2 +- .../references/project-responsibilities.md | 2 +- .../skills/purview-buildsdk-adoption/SKILL.md | 11 +- .../purview-buildsdk-configuration/SKILL.md | 25 +- .../references/properties.md | 28 +- .../SKILL.md | 16 +- .../references/detection.md | 17 +- .../SKILL.md | 21 +- .../SKILL.md | 138 +++- .../references/repository-root.md | 4 +- .../purview-buildsdk-skills-justfile/SKILL.md | 177 +++++ .../purview-component-compatibility/SKILL.md | 116 +++ .../references/lowest-compatible.md | 93 +++ .../references/purview-components.md | 60 ++ .../scripts/dependencies.mjs | 239 ++++++ .../purview-engineering-principles/SKILL.md | 39 +- .agents/skills/purview-org-harvest/SKILL.md | 197 +++++ .../references/best-in-class.md | 77 ++ .../references/harvest-commands.md | 121 ++++ .../purview-org-harvest/scripts/inventory.mjs | 466 ++++++++++++ .agents/skills/purview-persistence/SKILL.md | 65 +- .../references/ef-core-integration.md | 83 +++ .../references/guid-generation.md | 26 +- .../references/postgresql.md | 44 ++ .../references/sqlserver.md | 49 +- .../references/well-known-identifiers.md | 93 +++ .../SKILL.md | 2 +- .../SKILL.md | 8 +- .../purview-sourcegenerator-testing/SKILL.md | 4 +- .../purview-telemetry-benchmarks/SKILL.md | 277 +++++++ .../references/performance-doc-shapes.md | 117 +++ .agents/skills/purview-telemetry/SKILL.md | 3 +- .config/dotnet-tools.json | 2 +- .csharpierignore | 9 - .github/workflows/pr.yml | 28 + .gitignore | 1 + AGENTS.md | 83 ++- CHANGELOG.md | 82 +++ Directory.Packages.props | 42 +- Justfile | 42 +- README.md | 5 +- .../BenchmarkHelpers.cs | 22 +- .../Benchmarks/ActivityBenchmarks.cs | 4 +- .../Benchmarks/GeneratorModelBenchmarks.cs | 288 ++++++++ .../Benchmarks/LoggerBenchmarks.cs | 4 +- .../Benchmarks/LoggerMultiTargetBenchmarks.cs | 4 +- .../Benchmarks/MetricsBenchmarks.cs | 4 +- .../MultiTargetVsSingleTargetBenchmarks.cs | 4 +- .../Benchmarks/TagListBenchmarks.cs | 4 +- .../Net48Polyfills.cs | 10 + .../Purview.Telemetry.Benchmarks/Program.cs | 16 +- .../Purview.Telemetry.Benchmarks.csproj | 16 +- benchmarks/README.md | 94 ++- commitlint.config.mts | 2 +- docs/wiki/Diagnostics.md | 60 +- docs/wiki/Logging.md | 97 ++- docs/wiki/Metrics.md | 9 +- docs/wiki/Platform-Support.md | 65 +- docs/wiki/Release-Flow.md | 1 + docs/wiki/Tags-and-Baggage.md | 72 +- global.json | 2 +- package.json | 2 +- samples/SampleApp.Net48/README.md | 5 + .../SampleApp.Net48.ConsoleApp/Program.cs | 5 + .../Properties/AssemblyInfo.cs | 5 +- .../SampleApp.Net48.ConsoleApp.csproj | 1 + .../HostApplicationBuilderExtensions.cs | 37 +- .../SampleApp.Web/Clients/WeatherAPIClient.cs | 5 +- skills-lock.json | 62 +- skills/purview-telemetry-design/SKILL.md | 36 + skills/purview-telemetry-diagnostics/SKILL.md | 48 +- ...itySourceToTelemetryRefactoringProvider.cs | 36 +- ...rtILoggerToTelemetryRefactoringProvider.cs | 66 +- ...rtMetricsToTelemetryRefactoringProvider.cs | 32 +- .../AnalyzerReleases.Unshipped.md | 6 + .../Compatibility/ClosedTypeAttribute.cs | 6 +- ...ourceTargetClassEmitter.ActivityMethods.cs | 18 +- ...SourceTargetClassEmitter.ContextMethods.cs | 17 +- ...tySourceTargetClassEmitter.EventMethods.cs | 93 +-- ...ActivitySourceTargetClassEmitter.Fields.cs | 43 +- ...ctivitySourceTargetClassEmitter.Methods.cs | 93 ++- ...vitySourceTargetClassEmitter.Parameters.cs | 4 +- .../ActivitySourceTargetClassEmitter.cs | 3 +- .../DependencyInjectionClassEmitter.cs | 17 + .../Emitters/EmitterHelpers.cs | 5 +- .../Emitters/GeneratedTypesEmitter.cs | 410 ++++++----- .../LoggerGenTargetClassEmitter.Methods.cs | 167 +++-- .../Emitters/LoggerGenTargetClassEmitter.cs | 3 +- .../LoggerTargetClassEmitter.Methods.cs | 14 +- .../Emitters/LoggerTargetClassEmitter.cs | 3 +- ...TargetClassEmitter.InitializationMethod.cs | 43 +- .../MeterTargetClassEmitter.Methods.cs | 57 +- .../Emitters/MeterTargetClassEmitter.cs | 3 +- .../Emitters/UnionAccessorEmitter.cs | 48 +- .../TelemetrySourceGenerator.Activities.cs | 25 +- .../TelemetrySourceGenerator.Logging.cs | 31 +- .../TelemetrySourceGenerator.Metrics.cs | 25 +- ...TelemetrySourceGenerator.TelemetryNames.cs | 25 +- .../Generators/TelemetrySourceGenerator.cs | 17 +- .../Helpers/DiagnosticLibrary.General.cs | 8 +- .../Helpers/DiagnosticLibrary.Logging.cs | 72 ++ .../Helpers/InstrumentMethodModelBuilder.cs | 112 ++- .../Helpers/LogMethodModelBuilder.cs | 122 ++-- .../Helpers/LogPropertiesFlattener.cs | 209 ++++++ .../Helpers/PipelineHelpers.Activities.cs | 58 +- .../Helpers/PipelineHelpers.Logger.cs | 21 +- .../Helpers/PipelineHelpers.Metrics.cs | 15 +- .../Helpers/SharedHelpers.Logging.cs | 18 + .../Helpers/SharedHelpers.Metrics.cs | 79 ++ .../SourceGenerator/Helpers/SharedHelpers.cs | 90 +++ .../Helpers/TagProviderResolver.cs | 221 ++++++ .../Helpers/TypeLibraryGenerator.Logging.cs | 9 + .../Helpers/TypeParameterHelpers.cs | 119 +++ .../SourceGenerator/Helpers/UnionHelpers.cs | 327 ++++++++- .../Records/ActivityRecords.cs | 13 +- .../Records/AttributeDataModels.cs | 33 +- .../SourceGenerator/Records/GenericRecords.cs | 16 + .../SourceGenerator/Records/LoggerRecords.cs | 42 +- .../Records/MetricAttributeRecords.cs | 4 +- .../SourceGenerator/Records/MetricRecords.cs | 10 +- .../Records/TelemetryCapabilities.cs | 10 +- .../Records/TelemetryRules.Activities.cs | 19 +- .../Records/TelemetryRules.Logging.cs | 88 ++- .../Records/TelemetryRules.Metrics.cs | 2 +- .../SourceGenerator/Records/TelemetryRules.cs | 172 ++++- .../InstalledSkillTests.cs | 47 +- .../Repository.UnitTests/ReleaseGateTests.cs | 121 +++- .../TelemetryDiagnosticAnalyzerTests.cs | 24 +- .../UnsupportedTargetFrameworkTests.cs | 26 + ...urceGeneratorActivityTests.AddException.cs | 147 ++++ ...eneratorActivityTests.GenericInterfaces.cs | 29 + ...rySourceGeneratorActivityTests.Generics.cs | 34 + .../TelemetrySourceGeneratorActivityTests.cs | 20 +- ...metrySourceGeneratorGeneratedUnionTests.cs | 681 ++++++++++++++++++ ...ratorLoggingGen2Tests.GenericInterfaces.cs | 105 +++ ...ourceGeneratorLoggingGen2Tests.Generics.cs | 104 +++ ...oggingGen2Tests.LogPropertiesTransitive.cs | 259 +++++++ ...ceGeneratorLoggingGen2Tests.TagProvider.cs | 281 ++++++++ ...elemetrySourceGeneratorLoggingGen2Tests.cs | 8 +- .../TelemetrySourceGeneratorLoggingTests.cs | 8 +- ...GeneratorMetricsTests.GenericInterfaces.cs | 37 + ...trySourceGeneratorMetricsTests.Generics.cs | 64 ++ .../TelemetrySourceGeneratorMetricsTests.cs | 8 +- .../SharedHelpersActivityCapabilityTests.cs | 47 ++ ...haredHelpersMeterFactoryCapabilityTests.cs | 48 ++ 147 files changed, 8110 insertions(+), 1093 deletions(-) create mode 100644 .agents/skills/purview-buildsdk-skills-justfile/SKILL.md create mode 100644 .agents/skills/purview-component-compatibility/SKILL.md create mode 100644 .agents/skills/purview-component-compatibility/references/lowest-compatible.md create mode 100644 .agents/skills/purview-component-compatibility/references/purview-components.md create mode 100644 .agents/skills/purview-component-compatibility/scripts/dependencies.mjs create mode 100644 .agents/skills/purview-org-harvest/SKILL.md create mode 100644 .agents/skills/purview-org-harvest/references/best-in-class.md create mode 100644 .agents/skills/purview-org-harvest/references/harvest-commands.md create mode 100644 .agents/skills/purview-org-harvest/scripts/inventory.mjs create mode 100644 .agents/skills/purview-persistence/references/ef-core-integration.md create mode 100644 .agents/skills/purview-persistence/references/well-known-identifiers.md create mode 100644 .agents/skills/purview-telemetry-benchmarks/SKILL.md create mode 100644 .agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md delete mode 100644 .csharpierignore create mode 100644 benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/GeneratorModelBenchmarks.cs create mode 100644 benchmarks/Purview.Telemetry.Benchmarks/Net48Polyfills.cs create mode 100644 src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs create mode 100644 src/src/SourceGenerator/Helpers/TagProviderResolver.cs create mode 100644 src/src/SourceGenerator/Helpers/TypeParameterHelpers.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.GenericInterfaces.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.Generics.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorGeneratedUnionTests.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.GenericInterfaces.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.Generics.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GenericInterfaces.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.Generics.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersActivityCapabilityTests.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersMeterFactoryCapabilityTests.cs diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index 0619225f..55d0ca8b 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "2.0" + repository: purview-dev/engineering-playbook --- # Purview architecture testing @@ -83,7 +84,7 @@ debugging the package. Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable too. Full worked example: -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). ## 3. Read project files, and assemblies @@ -180,4 +181,4 @@ project classified so every dependency rule still applies to it. Prefer a waiver - `purview-architecture` — what the projects are for, and how they are named - `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API -- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index c04f3d61..0c435966 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -10,6 +10,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview architecture @@ -173,4 +174,4 @@ small telemetry — it does not get none. See `purview-telemetry`. - `purview-telemetry` — instrumentation at every scale - `purview-persistence` — storage and GUID-key guidance, only when persistence is real - [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) -- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md index e281a410..62b5a17f 100644 --- a/.agents/skills/purview-architecture/references/contract-strategy.md +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -230,7 +230,7 @@ combines" stays a review obligation, because a dependency matrix cannot express ## See also -- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape - [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema export diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index d74d2cf4..dc4e67cc 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -3,7 +3,7 @@ The catalogue in brief. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), and the full page — with diagrams — is -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). Do not restate either here or in another skill; link to them. ## The catalogue diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md index b1f222c5..bb24fa32 100644 --- a/.agents/skills/purview-buildsdk-adoption/SKILL.md +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -100,9 +100,14 @@ silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. ## 5. Agent skills are a separate, explicit step -The SDK does not install agent skills, and nothing it does during a build writes to an agent -directory. It reads which skills each referenced package ships and recommends the command that -installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does +the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. + +If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. ## Constraints diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index d82768b3..b540704b 100644 --- a/.agents/skills/purview-buildsdk-configuration/SKILL.md +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -41,7 +41,7 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | | `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | | `TestDataFramework` | `Bogus` | `Bogus` or `None` | -| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `TargetFramework` | `net10.0;net11.0` | `netstandard2.0` when `IsRoslynComponent=true` | | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | @@ -50,6 +50,11 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | | `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+: `0` off, `1` preview syntax, `2` updated rules | +| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ | +| `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | +| `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -89,11 +94,15 @@ is the SDK's answer, and it stays correct when the rules change. The exported se ## Repository bootstrap -The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. +The SDK bootstraps three repository-wide files from the package: `.editorconfig`, `global.json` and +`Directory.Build.rsp`. - `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` - `DisableAutoCopySdkFiles` — the master off switch - `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` +- `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that + enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a + non-`-mt` build) Every project in a solution writes those same destinations, so the write is race-tolerant by design: an existing file is never overwritten unless asked, each write is staged into a temporary @@ -105,6 +114,18 @@ the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPac and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see `purview-buildsdk-skill-installation`. +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: + +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | + +On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. + ## Guidance when changing SDK configuration 1. Preserve existing defaults unless the task explicitly changes product behaviour. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md index 1fb1f81b..82f84d77 100644 --- a/.agents/skills/purview-buildsdk-configuration/references/properties.md +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -21,7 +21,7 @@ consuming project supplies nothing. | Property | Default | Behaviour | | -- | -- | -- | -| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | | `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | | `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | | `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | @@ -36,6 +36,19 @@ consuming project supplies nothing. | `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | | `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | +## .NET 11 features and ServerShared + +| Property | Default | Behaviour | +| -- | -- | -- | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off, `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=false` also opts out | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | +| `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | +| `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | + ## Testing | Property | Default | Behaviour | @@ -119,6 +132,9 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | +| `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -136,7 +152,7 @@ nothing else was left for the mirror to deliver. Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, `PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, `PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, -`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. ## Compiler-visible properties @@ -148,7 +164,11 @@ Exported to analyzers and generators as `build_property.`: - `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` - `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`, + `IsServerSharedProject`, `IsClientSideProject` +- `PurviewAutoServerSharedReference`, `DisablePurviewServerSharedReferenceCheck`, + `PurviewServerSharedReferenceCheckAsError` +- `MemorySafetyRules`, `PurviewRuntimeAsync`, `PurviewMultiThreadedBuild` - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` @@ -163,6 +183,6 @@ Exported to analyzers and generators as `build_property.`: - `PurviewSuppressCopyRetryWarnings` - `PurviewSkillsSourceFolder`, `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, - `PurviewSkillsRunner` + `PurviewSkillsRunner`, `PurviewSkillsDryRun` - `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md index 8573ca40..bd80b3f5 100644 --- a/.agents/skills/purview-buildsdk-project-detection/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. license: MIT metadata: package: Purview.BuildSdk @@ -25,7 +25,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, -`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsTestProject`, `IsSharedProject`, `IsServerSharedProject`, `IsClientSideProject`, +`IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject`. @@ -52,11 +53,16 @@ a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each na suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. The 53 names are in [references/test-types.md](references/test-types.md). -### Shared and shared-testing projects +### Shared, shared-testing and ServerShared projects -Recognised by **exact name**, never by substring. The two lists are in +Recognised by **exact name**, never by substring. The three lists are in [references/detection.md](references/detection.md). Derived: `IsSharedProject`, -`IsSharedTestingProject`. +`IsSharedTestingProject`, `IsServerSharedProject`. + +`ServerShared` is separate from `Shared` for a reason: a `Shared` project is referenced by almost +everything, including code that ends up in a browser, so server-only helpers get their own name and +are referenced automatically only by a project that cannot be downloaded. `IsClientSideProject` is the +classification that decides it. ### SDK and content based diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md index 85fde09c..51243c96 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -5,6 +5,20 @@ `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` +## ServerShared project names (exact) + +`ServerShared`, `ServerSharedFramework`, `ServerSharedInfrastructure`, `ServerSharedInfra`, +`ServerSharedUtilities`, `ServerSharedUtils`, `ServerSharedLibrary`, `ServerSharedLib`, +`ServerSharedHelpers` + +A `ServerShared` project is referenced automatically by every non-client-side project, and never by a +WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a project named with a +client-side segment (`*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, +`*.Components`, or exactly `Components`/`ClientShared`), or a `Shared`/`SharedTesting` project. +`IsClientSideProject` overrides the classification. `PRSHR0001` reports a client-side reference and +`PRSHR0002` a `Shared` reference; `PurviewBrowserSafe="true"` on the reference records an accepted +decision. + ## Shared testing project names (exact) `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, @@ -64,7 +78,8 @@ dual-role component that wants normal library symbol packaging. | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | | Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | | Aspire host | `OutputType=Exe` | -| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | | Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | | BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | | `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md index ce1fd294..2be4c08f 100644 --- a/.agents/skills/purview-buildsdk-repository-review/SKILL.md +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-repository-review -description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. license: MIT metadata: package: Purview.BuildSdk @@ -89,7 +89,24 @@ go stale: is not any more. - A package the SDK already adds, restated in a project file, is maintenance cost with no effect. -## 6. Explain consequences, not just mismatches +## 6. Check how agent skills are managed + +Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package +reference that ships skills: + +- **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` + implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a + `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes + should only happen when somebody asks. +- **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and + pin package skills to their package's commit where the scripts install latest. See + `purview-buildsdk-skills-justfile`. +- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. +- **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. + If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. + +## 7. Explain consequences, not just mismatches For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, `AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md index 66c20a33..5c2694dc 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-skill-installation -description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. license: MIT metadata: package: Purview.BuildSdk @@ -9,7 +9,8 @@ metadata: # Installing the skills a package ships -For *authoring* skills, use `purview-buildsdk-skill-tests`. +For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-skills-justfile`. ## The one thing to understand first @@ -30,10 +31,22 @@ restore, a build, a design-time build or a test run. ```bash dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` -Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their -own, something has been wired up that should not be. +They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends +on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when +something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook +them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, +something has been wired up that should not be. + +Name **the solution**. Skills belong to the repository, so each target runs once however many +projects the solution holds: every project hands the work to the same elected leader, and MSBuild +runs it once. Package resolution reads every project in the solution, so a package only one project +references is still found. A single project works too, but sees only its own packages. For `just` +recipes that run these targets, see `purview-buildsdk-skills-justfile`. ## How a skill is resolved, and why the version is always right @@ -48,6 +61,57 @@ So the resolved ref is the commit the *restored package version* was built from. `PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility is exact by construction rather than by convention. +## Updating + +```bash +# after bumping the PackageReference +dotnet restore +dotnet msbuild -t:UpdatePurviewSkills +``` + +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: + +- a source a restored package ships is re-added at **that package's commit**, naming every skill + that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the + package version referenced; +- every other source is re-added at its latest with `--skill "*"`. + +That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both +refreshes the installed skills and adds any the source has gained; `skills update` would only +re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every +command and runs none. Each failed source is named (`PRSKL0008`). + +`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its +package's commit. Do not reach for a bare `skills update` instead; see below. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect +the agents on the machine. Set it to choose — in a project file `universal`, +or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` +itself splits on `,` and `;`: + +```bash +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +``` + +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. + +When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill +directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: + +```xml + +``` + +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. + ## Installing from an explicit source For a skill you want but have no `PackageReference` for: @@ -66,58 +130,78 @@ The caller owns the ref here — append `#` to pin it. This path needs no r | `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | | `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | | `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | -| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | | `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | +| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | | `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | | `PRSKL0012` | A skill cannot be verified | Message | | `PRSKL0013` | An orphaned lockfile entry | Message | | `PRSKL0014` | Recorded in the lockfile but never restored | Message | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | +| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | + +## Nothing about skills runs during a build -## Recommendations run during an ordinary build +This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. +It was removed after measurement — on a 39-project solution it read and regex-scanned every project's +`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` +calls, read the lockfile and walked the repository root, on every build, to print advice that a +repository managing its own skills had already acted on. All of its inputs are repository-scoped, so +the per-project cost bought nothing. -This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and -delegates to `RecommendPurviewSkills`, which can also be invoked by name: +The work is now paid only when somebody names a target: ```bash -dotnet msbuild -t:RecommendPurviewSkills +dotnet msbuild -t:RecommendPurviewSkills # report, never fail +dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 ``` `EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own `skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. -Four things keep it quiet, and all of them are load-bearing: +`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and +`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire +without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package +published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay +messages. Failing over something the caller cannot clear is how a gate gets switched off. + +Three things shape the output: -- Design-time builds, CI builds, non-default TFM passes and - `EnablePurviewSkillRecommendations=false` are skipped outright. - `PurviewSkillRecommendationsInCI=true` re-enables CI. -- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is - reported once per repository, not once per project and again on every rebuild. The explicit target - ignores the stamp. - Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. + **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes + unverifiable to a warning. - A correctly installed set produces no output at all. +- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, + `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the + hook: a stamp existed so a per-build hook could report one state once per repository, and a target + a person invokes should answer every time it is asked. Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so only an entry under an owner the project references — but from a repository it no longer references — is reported. A skill from any other owner is the developer's own, and is never mentioned. -### Drift never recommends `npx skills update` +### Drift never recommends a bare `skills update` -`update` installs the *newest* published skill, which is only correct when the newest skill happens -to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in -a pinned `npx skills add` instead. +Re-adding a source without a ref installs the *newest* published skill, which is only correct when +the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced +package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and +`-t:UpdatePurviewSkills` run for package skills. ## Package runners The CLI is published to npm, and every runner spells "run this npm package" differently. -`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). | Runner | Invocation | | -- | -- | +| `bun` *(default)* | `bun x skills@1` | | `npx` | `npx -y skills@1` | | `bunx` | `bunx skills@1` | -| `bun` | `bun x skills@1` | | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | @@ -156,11 +240,9 @@ two-pass behaviour and its home-directory bound are both load-bearing. The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore -the same set later with `npx skills experimental_install`. - -`PurviewSkillsAgents` (default `*`) narrows the target agents. +the same set later with `bun x skills experimental_install`. -The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +The lockfile is read **directly** rather than by parsing `skills list --json`, because that listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs no Node tooling, which is what keeps recommendations working on a machine that can build .NET but has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md index e90b695c..d5561745 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -44,7 +44,9 @@ an agent directory somewhere that is not the repository. A property a dependency computes is still empty in the depending target's `Condition`. That is why the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on -`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. +`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or +`FixPurviewSkills`. ## The managed port diff --git a/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md new file mode 100644 index 00000000..2da36a29 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md @@ -0,0 +1,177 @@ +--- +name: purview-buildsdk-skills-justfile +description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Justfile recipes for the agent-skill targets + +The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are +covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` +correctly. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, + `.husky/`) or a CI job that commits; +- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. + +## Recipes + +Put them in their own file beside the `Justfile`, and import it from the `Justfile`: + +```just +# Justfile +import "Justfile.skills.just" +``` + +The only line that differs between repositories is `skills_project`. Work it out from the repository +as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not +leave a placeholder: + +```just +skills_project := solution_file # or a path, when the Justfile has no such variable + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} + +# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) +[group('Agent skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without changing anything +[group('Agent skills')] +skills-recommend *args: + dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} + +# Fail when a referenced package's skills are missing or drifted - the CI gate +[group('Agent skills')] +skills-verify *args: + dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} +``` + +Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. + +### Choosing `skills_project` + +Take the first of these that applies: + +1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` + or similar, typically used by `build`, `test` and `restore`. Reference it: + `skills_project := solution_file`. An imported file shares the importing file's variables, so the + skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. +2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that + path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference + that, but do not restructure the `Justfile` unasked. +3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring + `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to + the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that + still leaves more than one. +4. **No solution at all.** Name a project that imports the SDK and references every package that + ships skills, and say in a comment that it sees only its own packages. + +An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace +the project with the solution, chosen as above. Remove any comment explaining why that project was +picked, because the reason no longer holds. + +### Details that break a recipe if missed + +- **Name the solution.** Every skill target runs once for the whole solution, however many projects + it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package + resolution reads every project in the solution, so a package only a test project references is + still pinned. There is no project to choose and nothing to keep in step as projects come and go. + A single project also works, but sees only that project's packages. (SDK releases before this + behaviour ran the target once per project; there, name one project that references every package + that ships skills.) +- **Restore first.** The targets read `project.assets.json` and never restore on their own + (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats + the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, +so a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so +escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +Useful properties to pass through `*args`: + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | + +## Replacing hand-written skill scripts + +Repositories often carry their own scripts for this. The SDK targets replace them: + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Two behaviours differ, deliberately — mention them when migrating: + +- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its + skills keep matching the package version. Other sources are re-added at latest, as before. +- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a + package source gets every skill its referenced version ships, and any other source is re-added with + `--skill "*"`. + +After switching, delete the scripts and their `package.json` entries, and remove `package.json` +itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran before deleting anything. + +## Updating an existing Justfile + +1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` + (a PowerShell shell still needs the `-t:` value quoted). +2. Keep recipe names the repository already uses; change only what they run. If `skills-check` + already means "report", rename it to `skills-recommend` rather than silently changing its meaning, + and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, + can point at the solution instead. +3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows + and hook configuration for the recipe name. +4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the + commands, and `just skills-check` passes or names a real problem. diff --git a/.agents/skills/purview-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 00000000..b017681a --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 00000000..ccdb3b69 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 00000000..6e13b659 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 00000000..3069ef62 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md index ba4dd9bd..24fe707c 100644 --- a/.agents/skills/purview-engineering-principles/SKILL.md +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,9 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -13,7 +14,7 @@ metadata: # Purview engineering principles -Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction that matters throughout: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. @@ -100,8 +101,9 @@ repository's own tests already are the consumer. ## 7. Documentation is a build artefact of the repository -The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its -`mkdocs.yml`, its navigation and its `edit_uri`. +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is wrong within one release. @@ -154,6 +156,23 @@ hand-written attribute. *At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by attribute. +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + ## Applying these in review Three questions catch most of it: @@ -164,8 +183,16 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern -guidance. +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 00000000..2c05ea19 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 00000000..9ab69ccd --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 00000000..f4b46537 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 00000000..affc46fa --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md index 4667fa29..7d6ad911 100644 --- a/.agents/skills/purview-persistence/SKILL.md +++ b/.agents/skills/purview-persistence/SKILL.md @@ -1,14 +1,15 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" + repository: purview-dev/engineering-playbook --- # Purview persistence @@ -40,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -72,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -85,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -96,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 00000000..77c75731 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md index 432053a2..fb644bb8 100644 --- a/.agents/skills/purview-persistence/references/guid-generation.md +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md index cd557ecc..603987d6 100644 --- a/.agents/skills/purview-persistence/references/postgresql.md +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md index c7282955..22629721 100644 --- a/.agents/skills/purview-persistence/references/sqlserver.md +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 00000000..372d5855 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md index ee363c97..68e79d3c 100644 --- a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -234,7 +234,7 @@ Guidelines: - The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. - The consumer's `TargetFramework` does not determine analyser compatibility. - Choose the oldest Roslyn version that contains the APIs you need. -- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10, 5.12 for .NET 11. - Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. - Do not mistake multi-targeting for automatic analyser asset selection. - Use `PrivateAssets="all"` for Roslyn development dependencies. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md index a0e7f802..a34f72c4 100644 --- a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -185,10 +185,12 @@ marker is the preferred configuration. | 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | | 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | | 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | +| 5.12 | VS 2026 18.x | C# 15 / .NET 11 | -**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later -(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and -Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. +**This framework is built against Roslyn 5.9**, so a host loading it must ship Roslyn 5.9 or later +(.NET 11 SDK / Visual Studio 2026). The .NET 11 SDK ships Roslyn 5.12. The testing packages +multi-target `netstandard2.0`, `net10.0`, and `net11.0`, and Roslyn 5.9 ships `net10.0`/ +`netstandard2.0` assets, so both test targets load the runner. ## A generator project to start from diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md index a6da554c..5590a896 100644 --- a/.agents/skills/purview-sourcegenerator-testing/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -215,8 +215,8 @@ var model = LogAttributeData.FromAttributeData(attribute); runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal reference. - **Multi-target**: build generators against the Roslyn version that supports the test matrix; the - framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep - `System.Collections.Immutable` version pinned to the shared one. + framework is built against Roslyn 5.9 (`net10.0`/`netstandard2.0` assets keep a .NET 10–11 matrix + loading); keep `System.Collections.Immutable` version pinned to the shared one. - **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes undisposed `CodeWriter` scopes fail tests. - **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). diff --git a/.agents/skills/purview-telemetry-benchmarks/SKILL.md b/.agents/skills/purview-telemetry-benchmarks/SKILL.md new file mode 100644 index 00000000..0521d502 --- /dev/null +++ b/.agents/skills/purview-telemetry-benchmarks/SKILL.md @@ -0,0 +1,277 @@ +--- +name: purview-telemetry-benchmarks +description: >- + Maintainer workflow for this repository's BenchmarkDotNet suite - running the .NET 10, .NET 11 and + .NET Framework 4.8 jobs, reading a -report-github.md, refreshing docs/wiki/Performance.md and + README.md from a fresh run, writing the analysis consumers read, and adding a benchmark family. + Use when asked to run, update, interpret, or extend the benchmark results. This is a developer + skill for this repository; it is never published to consumers. +metadata: + product: Purview Telemetry Source Generator + audience: maintainers + repository: purview-dev/telemetry-sourcegenerator +--- + +# Running and documenting the telemetry benchmarks + +This skill is for developers **working on** the generator. It covers the whole loop: run the suite, +read the reports, update the two performance documents, and write the analysis a consumer needs to +make a decision. It is not consumer guidance, and it is not published - this repository's +consumer-facing skills live in `skills/` and are installed with `npx skills add`. + +Load this skill before you quote, edit, or add a performance number. + +## Non-negotiables + +1. **Every published number comes from one fresh Release run.** Never copy a stale table, never + estimate, never interpolate between runtimes. If you cannot run the suite, say so instead of + editing the docs. +2. **Debug numbers are meaningless.** `just benchmark` and `just benchmark-docs` pin `Release` + deliberately; do not substitute `just build`-style Debug output. +3. **Never mix rows from different runs, machines or SDKs** in one table. A table is one report. +4. **Never cancel a benchmark run.** The full suite takes tens of minutes. To shorten the loop, + filter to one family - do not kill a run half-way and keep its numbers. +5. **Observable instruments are not benchmarked by design** (`ObservableCounter`, + `ObservableGauge`, `ObservableUpDownCounter`): they register a callback once and have no + per-operation hot path. Do not add them, and do not remove the explanatory paragraph that says so. +6. **`BenchmarkDotNet.Artifacts/` is gitignored.** It is a local scratch folder, not a published + artifact, so a doc must not link to it on GitHub. The CSV/HTML files stay on the machine that ran + them; the report numbers are what get committed. + +## The suite at a glance + +| Benchmark class | What it measures | Feeds | +| --- | --- | --- | +| `ActivityBenchmarks` | Generated vs hand-written Activity telemetry, `HasListener` true/false | README § Activities, wiki § Activities | +| `LoggerBenchmarks` | Generated v1 (`LoggerMessage.Define`) and v2 (`ThreadLocalState`) vs manual, `HasLogging` true/false | README § Logging, wiki § Logging | +| `MultiTargetVsSingleTargetBenchmarks` | Activity-only vs Activity+Logging+Metrics from one call, `HasListener` | README § Multi-Target, wiki § Multi-target | +| `LoggerMultiTargetBenchmarks` | Logging-only vs the full multi-target stack, v1/v2/manual | wiki § Logger multi-target | +| `MetricsBenchmarks` | Counter / UpDownCounter / Histogram, generated vs manual, 0 and 1 tag | README § Metrics, wiki § Metrics | +| `TagListBenchmarks` | Inline `KeyValuePair` (< 4 tags) vs stack-allocated `TagList` (4+ tags) | README § Metrics, wiki § Metrics | +| `GeneratorModelBenchmarks` | The generator's own `closed record` models vs a value-based `[Union]` struct, per method count | wiki § Generator model overhead | + +The first six measure code a consumer runs. `GeneratorModelBenchmarks` measures the generator's own +data model and is contributor-facing: it backs the recorded decision in +`docs/wiki/Platform-Support.md` to keep the `closed record` hierarchies. Do not promote it into the +README's consumer performance section. + +Exact table shapes, section headings and column headers for each family live in +[`references/performance-doc-shapes.md`](references/performance-doc-shapes.md). + +## Supported runtimes and jobs + +One invocation measures all three legs, because every class declares one `[SimpleJob]` per runtime: + +| Attribute | Runtime | Notes | +| --- | --- | --- | +| `[SimpleJob(RuntimeMoniker.Net10_0)]` | .NET 10 | The line `Directory.Packages.props` targets. | +| `[SimpleJob(RuntimeMoniker.Net11_0)]` | .NET 11 | Needs BenchmarkDotNet **0.16.0-preview.2+**; 0.15.8's `RuntimeMoniker` stops at `Net10_0`. | +| `[SimpleJob(RuntimeMoniker.Net48)]` | .NET Framework 4.8 | Windows only. The `netstandard2.0` asset path - the only way to compare it against the modern runtimes. | + +- The suite is *launched* with `--framework net11.0`; that only selects the BenchmarkDotNet host. The + job attributes decide what is measured, so the report carries a section per runtime. +- `RuntimeMoniker.Net47` is deliberately absent: its generated host project fails to restore on every + run and only ever produced `NA` rows. Do not add it back. +- `RuntimeMoniker.Net12_0` already exists in the pinned preview package, so a future runtime needs no + dependency change - but re-check `BenchmarkDotNet.Annotations.dll` before claiming a moniker exists. +- The project pins its own `BenchmarkDotNet` version; it is not in `Directory.Packages.props`. + `benchmarks/` sits outside `src/`, so it does not import `Purview.BuildSdk`. + +Prerequisites: the SDK pinned in `global.json`, plus the .NET 10 and .NET 11 runtimes installed +(each job resolves its own runtime), and Windows for the `net48` leg. + +## Run it + +```bash +# Full suite, all three runtimes, checked-in Release configuration (tens of minutes) +just benchmark + +# Same, then prints the docs-update checklist +just benchmark-docs + +# Short-job smoke test, own artifacts folder - never a source for published numbers +just benchmark-quick +``` + +Direct invocation, when you need to filter or pass extra arguments: + +```bash +dotnet run --project ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj \ + --configuration Release --framework net11.0 -- --filter "*ActivityBenchmarks*" +``` + +- `--filter` matches the fully-qualified class name; `"*MultiTarget*"` also catches + `LoggerMultiTargetBenchmarks`, so prefer the exact class name when you want one family. +- `--list flat` lists every benchmark without running anything. +- `Program.cs` appends `--filter *` when the arguments select nothing, so `--artifacts ` or + `--job short` alone no longer drops into the interactive prompt. A `--filter` or `--list` you pass + is forwarded untouched. +- `--artifacts ` redirects output; `just benchmark-quick` uses `BenchmarkDotNet.Artifacts/quick` + so a smoke run cannot overwrite the full suite's report. +- `benchmark-quick` also passes `--runtimes net11.0` while the classes declare their own jobs. Treat + it as "short job, smoke only" and read the report header to see which runtimes actually ran - never + assume its scope, and never lift a number from it. + +## Read a report + +Each benchmark class writes three files to `BenchmarkDotNet.Artifacts/results/`: + +``` +Purview.Telemetry.Benchmarks.Benchmarks.-report-github.md # the table to copy +Purview.Telemetry.Benchmarks.Benchmarks.-report.csv # Error/StdDev/Median live here +Purview.Telemetry.Benchmarks.Benchmarks.-report.html # full detail, per-method +``` + +The `-report-github.md` opens with a fenced environment block: BenchmarkDotNet version, OS build, CPU, +RAM, SDK, then one line per job (`[Host]`, `Job-XXXXX`) with its exact runtime version. That block is +the source for both documents' environment headers, and it is the only honest answer to "which +runtimes did this run measure?". + +Column semantics: + +- **Ratio** is relative to the row marked `Baseline = true` in that class and parameter group, not to + the manual implementation in general. `0.98` means 2% faster than the baseline. +- **Allocated** `-` means no allocation on that path. Do not claim "zero allocations" for a row that + shows bytes; `Gen0`/`Gen1` are the GC counts that come with them. +- **Alloc Ratio** appears only where allocations differ between rows. +- `Error`, `StdDev`, `Median`, `RatioSD` are hidden by `[HideColumns]` to keep the GitHub table + narrow. Read them from the CSV or HTML before calling a difference real. +- The bold row is the baseline. Keep that bolding when you copy a table. +- A run that reports "Benchmarks with issues" or `NA` rows is a broken run. Fix the cause (usually a + missing runtime or a host-project restore failure) and re-run; do not document the gap. + +## Update the documents + +Do these in order, from the same run. Nothing below is optional because "it looked the same". + +1. **`docs/wiki/Performance.md`** - the full cross-runtime page. + - `## Environment`: replace the fenced block with the fresh report header (BenchmarkDotNet, OS, CPU, + SDK, and one line per measured runtime). + - One section per family, in the existing order, each with a row per runtime and per parameter + value. Keep the `**Source:**` line naming the class. + - `GeneratorModelBenchmarks` has no section yet: add `## Generator model overhead` on the first + refresh that carries its report, and keep it wiki-only. + - Rewrite each `**Interpretation:**` paragraph from the new numbers (see "Write the analysis"). + - Delete stale caveats the new run makes untrue - the "`.NET 11.0` is not measured yet", + "`.NET 8`/`.NET 9` rows were removed", and ".NET Framework produced no results" notes - and add a + caveat only for a limitation that still exists. + - Leave the `## Observable instruments` paragraph alone unless the generator's design changes. +2. **`README.md`** - `## Performance`, the consumer-facing digest. + - One environment line (machine + SDK) matching the fresh run; it currently reads + "Benchmarked on ... .NET SDK ...". + - One table per family, deliberately narrower than the wiki: the headline runtime only. Keep the + runtime in the heading and in the environment line consistent - if the headline runtime changes, + change the heading, the sentence and the environment line together. + - Keep the prose that tells a consumer what the number means ("generated activities match + hand-written code with identical allocations"), refreshed against the new ratios. +3. **`benchmarks/README.md`** - only when the suite itself changed: the family table, the "runtimes and + jobs" table, the per-family detail sections and the project-structure tree all list the classes and + the monikers. +4. **`docs/wiki/Platform-Support.md`** - when `GeneratorModelBenchmarks` numbers back (or contradict) + the recorded decision to keep the `closed record` models, update that row's reasoning. It currently + says the tables predate the benchmark; once they do not, say so. +5. **`AGENTS.md`** - the "Benchmarking and performance documentation" section carries the commands, the + three runtimes and the moniker constraints. It changes only when the suite changes. +6. **`CHANGELOG.md`** - only when a result changes a documented decision or adds a family, not for a + routine refresh of the numbers. + +Never edit `BenchmarkDotNet.Artifacts/**` by hand, and never commit it. + +## Write the analysis + +The tables are data; the paragraph after them is the product. A consumer reads one sentence to decide +whether generated telemetry is safe to adopt. + +Cover these four things, in this order: + +1. **What is compared** - the baseline and the alternatives, and whether the parameter (listener + present, logging enabled) represents the production path. +2. **The delta** - time ratio and allocation side by side, for the runtime you are quoting. "Within 2%" + and "identical allocation" are different claims; make both or neither. +3. **Why** - name the mechanism when you know it: framework work dominating Activity creation, the + `IsEnabled` short-circuit, `[MethodImpl(AggressiveInlining)]` folding the generated call into the + caller, the inline `KeyValuePair` vs `TagList` threshold at 4 tags. +4. **The caveat** - machine, SDK, and the fact that numbers on another host will differ. + +Rules of thumb: + +- Sub-10 ns means are noise-dominated. Check `Error`/`StdDev` in the CSV before attributing a 0.02 ns + difference to generated code; usually the honest sentence is "indistinguishable". +- Do not compare a runtime's row to another runtime's row as a claim about generated code - the runtime + dominates. Compare generated vs manual *within* a runtime. +- Do not compare this run's numbers to the previous run's as a regression unless the machine and SDK are + the same; state the comparison basis if you do. +- Every ratio you quote must exist in the report. If you want to say "~7%", the rows must say + `1.07`. +- Keep the consumer's frame: absolute nanoseconds for the hot path, allocations per call, and whether + the generated code is on par with "what you would write by hand". + +Anti-patterns to avoid: "blazing fast", "no overhead" for a measured 1.1x, quoting a Debug run, quoting +the `HasListener = false` row as the production cost, and describing a family the run did not measure. + +## Add or change a benchmark family + +1. `Telemetry/` - the interface(s) the generator consumes (`[ActivitySource]`, `[Logger]`, `[Meter]`, + or the multi-target combination). Give the v1 logging variant its own interface + (`GenerationMode = LoggerGenerationMode.V1`) if the family compares generations. +2. `Manual/` - the hand-written baseline, written the way a careful developer would: `LoggerMessage.Define` + for logging, `Create*` for instruments, `ActivitySource.StartActivity` for activities. A weak baseline + invalidates the comparison. +3. `BenchmarkHelpers.cs` - a factory that builds every implementation for a fair comparison: one logger and + one `SimpleMeterFactory` shared across the manual and generated instances, an always-sampling + `ActivityListener` for the listener-present parameter. +4. `Benchmarks/Benchmarks.cs` - required shape: + - `[MemoryDiagnoser]`, `[HideColumns("Error", "StdDev", "Median", "RatioSD")]`, and the three + `[SimpleJob]` attributes above, in that order. + - `[Params(true, false)]` for `HasListener`/`HasLogging`; do not silently measure only one side. + - Exactly one `Baseline = true`, on the row a consumer would compare against (usually manual). + - `Description` strings that read as a sentence in the table - they are what the docs copy. + - A `` that says what question the benchmark answers and why the pair is fair. This is where + the mechanism the analysis will cite is recorded. + - `[GlobalSetup]` / `[GlobalCleanup]` dispose meters, loggers and listeners. +5. Register it: `benchmarks/README.md` (family table + project tree), `docs/wiki/Performance.md` (new + section with one row per runtime), and `README.md` only if it is a consumer hot path. +6. Validate as described below, then run the full suite - a new family cannot be documented from a + filtered run that never executed it on all three legs. + +`[SimpleJob]` on the class, not `[SimpleJob]` on a host runtime: a host-runtime job carries no `Runtime` +characteristic and fails BenchmarkDotNet's `GetSupportedBenchmarks` validation instead of measuring. + +## `GeneratorModelBenchmarks` specifics + +- It replicates the generator's internal models inside the benchmark project, because the generator's + records are internal to the analyzer assembly. Keep the replica faithful to + `src/src/SourceGenerator/Records/` when either shape changes, and say so in the class remarks. +- It exists to answer one question: is the per-method allocation and virtual dispatch of the record + hierarchy measurable against a pass that also parses symbols, builds templates and writes source? A + delta of tens of nanoseconds per method against a generation pass is not a throughput story. +- Report it in `docs/wiki/Performance.md` only, with a row per runtime and per `MethodCount`. A + `.NET Framework` result that is *slower* for the struct union is a real outcome, not a mistake - it is + part of why the records stayed. + +## Validate before committing + +- `just lint` - CSharpier, no writes. `just lint-fix` to apply. Any C# you touched in `benchmarks/` is + formatted by the same check. +- `dotnet build ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj --configuration Release` + - a compile check of a new or edited family that costs seconds instead of a benchmark run. Note that + `just build` does **not** cover this project: it is outside `src/` and not in the main solution, so a + broken benchmark class passes the normal build. +- Re-read both documents against the report you were given: every row traces to the report, every + environment line matches the report header, no stale caveat survives, and no table mixes runs. +- Re-read the interpretation paragraphs for claims the report does not support. +- A docs-only change needs no test run; a change to the suite needs `just benchmark` (full) before its + numbers are documented. +- Commit as `docs:` for a numbers refresh, `perf:` or `test:` for a suite change, per the conventional + commit rules in `AGENTS.md`. + +## Traps that have bitten before + +- `just build` is green while `benchmarks/` does not compile - the project is not in the solution. +- `--filter "*MultiTarget*"` matches two classes; the report then covers only part of what the docs claim. +- `net48` silently disappears on a non-Windows machine, and with it the `netstandard2.0` comparison. +- A missing .NET 10 or .NET 11 runtime turns a leg into `NA` rows rather than a failure. +- `BenchmarkDotNet.Artifacts/` is gitignored, so a "raw results" link to it on GitHub is a dead link. +- `benchmark-quick` output looks like a result but is a short job; publishing it overstates precision. +- The README headings pin a runtime (".NET 10.0"). Refreshing the table without refreshing the heading + is how a doc ends up claiming one runtime while showing another. diff --git a/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md b/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md new file mode 100644 index 00000000..8efae4d7 --- /dev/null +++ b/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md @@ -0,0 +1,117 @@ +# Performance document shapes + +The exact shapes the two performance documents use today. Preserve them: a consumer reads the same +table layout release after release, and a diff should show changed numbers, not a reshaped page. + +## `docs/wiki/Performance.md` + +Order of sections: + +1. `# Performance` - one sentence: what the page is and which project produced it. +2. `## Reproducing` - the `dotnet run` command, the artifacts path and file types, and the + `[!IMPORTANT]` Release-only callout. +3. `## Environment` - a fenced block copied verbatim from a `-report-github.md` header, then any caveat + notes that are still true. +4. One `##` section per family, in this order: `Activities`, `Logging`, `Multi-target`, + `Logger multi-target`, `Metrics`, `Generator model overhead`, `Observable instruments`. + `Generator model overhead` has no table yet — write it on the first refresh that includes a + `GeneratorModelBenchmarks` report. +5. `## Raw results` - only what is actually published; the artifacts folder is gitignored, so this + section must not claim a GitHub link to it exists. +6. `## See also` - links to the README summary and the related wiki pages. + +Each family section: + +```markdown +## Activities + +**Source:** `ActivityBenchmarks` + +One paragraph: what is compared against what, and what the parameter does. + +| Method | Runtime | HasListener | Mean | Ratio | Allocated | +| --- | --- | --- | --- | --- | --- | +| **Manual: start + complete** | **.NET 10.0** | **True** | **220.37 ns** | **1.00** | **1008 B** | +| Generated: start + complete | .NET 10.0 | True | 216.69 ns | 0.98 | 1008 B | +... + +**Interpretation:** the four-part analysis - comparison, delta, mechanism, caveat. +``` + +Conventions in the tables: + +- Column order: `Method | Runtime | | Mean | Ratio | Allocated`, plus `Alloc Ratio` where + allocations differ, and `Gen0`/`Gen1` where the report shows them. +- Rows are grouped by runtime, with the `.NET 10.0` block first, then `.NET 11.0`, then + `.NET Framework 4.8`; within a block the parameter groups of one class sit together. +- The baseline row of each block is bold, including its bold `1.00` ratio. +- Means carry the report's unit and precision (`ns`, `µs`, `ms`); allocations carry `B` or `-`. +- Method names are the report's `Description` strings, shortened only where the report already + shortened them. + +Family-specific parameters: + +| Section | Parameters | Columns | +| --- | --- | --- | +| Activities | `HasListener` | `Mean | Ratio | Allocated` | +| Logging | `HasLogging` | `Mean | Ratio | Allocated` | +| Multi-target | `HasListener` | `Mean | Ratio | Allocated | Alloc Ratio` | +| Logger multi-target | `HasListener` | `Mean | Ratio | Allocated` | +| Metrics | - (two tables: instruments, then tag counts) | `Mean` per scenario | +| Generator model overhead | `MethodCount` | `Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio` | + +The Metrics section is the one deliberate exception: it is two scenario tables (`MetricsBenchmarks` +generated-vs-manual, then `TagListBenchmarks` by tag count) followed by the tag-count threshold +explanation, because a column-per-implementation table would be 3x wider than the page. + +## `README.md` section + +```markdown +## Performance + +Benchmarked on , .NET SDK . See the [Performance](https://purview.dev/docs/telemetry-sourcegenerator/performance/) page for full cross-runtime results. + +### Activities (.NET 10.0) + +| Scenario | HasListener | Manual | Generated | Ratio | +| --- | --- | --- | --- | --- | +| start + complete | False | 0.53 ns | 0.58 ns | 1.11x | +... +``` + +- The heading carries the headline runtime in parentheses. It must match the rows beneath it. +- Columns squeeze the wiki table into scenario rows: a scenario column, the parameter column, one column + per implementation, and `Ratio`. +- Allocations are folded into the cell when they matter (`220 ns / 1008 B`), not given their own column. +- One short sentence per family states the conclusion a consumer needs; it is not a shortened copy of + the wiki's interpretation. +- Metrics keeps its `Notes` column for the `TagList` threshold. + +## Environment header + +Copy it, do not compose it: + +``` +BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26300.9974) +13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores +Memory: 63.77 GB Total, 35.44 GB Available +.NET SDK 11.0.100 + [Host] : .NET 11.0.0 (11.0.0, 11.0.26.53018), X64 RyuJIT x86-64-v3 + Job-GVKUBM : .NET 10.0.15 (10.0.15, 10.0.1526.53008), X64 RyuJIT x86-64-v3 + Job-IHFIKV : .NET 11.0.0 (11.0.0, 11.0.26.53018), X64 RyuJIT x86-64-v3 + Job-UGENHH : .NET Framework 4.8 (4.8.10008.0), X64 RyuJIT VectorSize=256 +``` + +Drop the `Job-XXXXX` ids if you prefer, but keep the exact runtime versions, the SDK version, the CPU and +the OS build - those are what make two runs comparable. The README's one-line environment must agree with +this block's CPU and SDK. + +## Interpretation paragraph checklist + +- [ ] Names the baseline and the alternatives. +- [ ] Gives the time delta **and** the allocation delta for the production path. +- [ ] Names the mechanism when it is known (framework cost, inlining, `IsEnabled` short-circuit, tag + threshold). +- [ ] States the caveat (machine/SDK) or the boundary of the claim. +- [ ] Contains no number that is absent from the report. +- [ ] Would still be true if the reader only ever saw the table, not the report. diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md index 6d831731..d2f42d78 100644 --- a/.agents/skills/purview-telemetry/SKILL.md +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview telemetry @@ -120,4 +121,4 @@ Instrumentation is identical across all three rows. Only the right-hand column g ## See also - `purview-architecture` — establishing project context and scale before deciding -- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index ae7b7732..bc9bc278 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.3.0", + "version": "1.4.0", "commands": [ "csharpier" ], diff --git a/.csharpierignore b/.csharpierignore deleted file mode 100644 index c457adf3..00000000 --- a/.csharpierignore +++ /dev/null @@ -1,9 +0,0 @@ -# CSharpier 1.3.0 (the newest release) parses with a Roslyn that predates C# 15, so the two files that use the -# `closed` modifier fail to parse and are reported as "not formatted due to syntax errors", which fails -# `just lint`. They are excluded rather than dropped, because `closed` is what makes a switch over the -# activity-method and instrument hierarchies compiler-checked for exhaustiveness. -# -# `dotnet format` (`just format`) still analyses and formats them: it runs on the SDK's Roslyn, which -# understands C# 15. Remove these entries once CSharpier ships C# 15 support. -src/src/SourceGenerator/Records/ActivityRecords.cs -src/src/SourceGenerator/Records/MetricAttributeRecords.cs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index af181942..f964d4fc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,6 +14,34 @@ jobs: uses: purview-dev/build/.github/workflows/purview-build.yml@main secrets: inherit + # Proves the lower bound this package declares. `Directory.Packages.props` keeps each package family on + # one property, and `DependencyFloor=true` drops every family to the oldest version the generator compiles + # and passes against. Dependency resolution is a set of lower bounds, so the floor declared here becomes + # the floor every consumer inherits; a floor nobody builds against is a guess. The `build` job above is the + # ceiling - it resolves the latest servicing patch of the same line. + dependency-floor: + name: Lowest compatible dependency floor + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + - name: Restore, build and test at the declared floor + run: >- + dotnet test --solution src/Telemetry.SourceGenerator.slnx + --configuration Release + --timeout 30m + --maximum-failed-tests 25 + --results-directory-layout per-module + -p:DependencyFloor=true + samples: name: Build and test samples runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7340a12d..7f79a160 100644 --- a/.gitignore +++ b/.gitignore @@ -199,6 +199,7 @@ node_modules/ BenchmarkDotNet.Artifacts/!scripts/* !build/ .tools/ +.bdn/ # Agent skills are installed into the universal .agents/skills/ folder only, and that folder and # skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. diff --git a/AGENTS.md b/AGENTS.md index ca476bb6..2f46aea4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ All `just` recipes read the repository's [`Justfile`](Justfile). Configuration d | --- | --- | | `just build` | Builds `src/Telemetry.SourceGenerator.slnx` (Debug). | | `just test` | Runs the test solution (Debug) with a tree-node filter. | +| `just test-floor` | Runs the test solution against the declared dependency floor (`-p:DependencyFloor=true`). | | `just build-s` | Builds `samples/SampleApp/SampleApp.slnx`. | | `just test-s` | Runs the sample test solution. | | `just build-mp` | Builds `samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx`. | @@ -89,12 +90,13 @@ These mirror the GitHub CI/CD workflows and are the supported way to run a full | Command | Purpose | | --- | --- | -| `just benchmark` | Runs the full benchmark project on `net10.0` (Debug config). | -| `just benchmark-quick` | Single-runtime `net10.0` run with a short BenchmarkDotNet job. | +| `just benchmark` | Runs the full suite — .NET 10, .NET 11 and .NET Framework 4.8 — in one Release invocation. | +| `just benchmark-quick` | Single-runtime `net11.0` run with a short BenchmarkDotNet job and its own artifacts folder. | | `just benchmark-docs` | Runs benchmarks, then prints a checklist of docs to update. | -> BenchmarkDotNet numbers are only meaningful from **Release** builds. When measuring performance, -> run the benchmark project directly with `--configuration Release` (see `benchmarks/README.md`). +> The recipes pin **Release**, because BenchmarkDotNet numbers from a Debug build are not meaningful. Every +> benchmark job names its runtime explicitly, so one report in `BenchmarkDotNet.Artifacts/results/` carries all +> three runtimes and the launch framework only selects the BenchmarkDotNet host. ### Timeouts and cancellations @@ -131,7 +133,7 @@ benchmarks/ └── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net10.0) .build/ # TS scripts (build-pack.ts, update-version.ts) -.agents/skills/ # Installed skills from other repos, committed (see "Installed skills") +.agents/skills/ # Installed + developer skills, committed (see "Installed skills") skills-lock.json # The skills CLI lockfile for .agents/skills/, committed skills/ # Authored skills shipped by this package (see "Authoring skills") .config/ # dotnet-tools.json, lefthook.yml @@ -161,6 +163,9 @@ Treat work as incomplete until every gate relevant to it passes. Before a releas - `just scrub`, then `just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/` — restore, build with no new warnings, lint, tests, pack and pack validation (`RequiredContent` and `ForbiddenContent`). +- `just test-floor` — the lowest compatible dependency leg. Every version this package declares is a lower + bound, so the floor becomes every consumer's floor; this proves it builds and passes rather than assuming it. + The `dependency-floor` PR job runs the same leg, and `ReleaseGateTests` fails when either wiring is removed. - `just smoke-packed-consumers` — packs the generator to an isolated feed and cache and proves the `.nupkg` itself works: a Native AOT console (published with `-warnaserror`, then run, asserting the emitted activities, metrics and log entries, log-level gating and multi-target methods), a Blazor @@ -203,10 +208,10 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel - **Conventional commits**: enforced by commitlint (`.config/lefthook.yml`). Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. -- **Formatting**: C# is formatted with csharpier; check with `just lint`. CSharpier 1.3.0 (the newest release) - parses with a Roslyn that predates C# 15, so the two files that declare a `closed` hierarchy are listed in - `.csharpierignore` rather than reported as syntax errors; `dotnet format` (`just format`) still covers them, - and the entries come out when CSharpier supports C# 15. +- **Formatting**: C# is formatted with csharpier; check with `just lint` and fix with `just lint-fix`. CSharpier + 1.4.0 parses C# 15, so the two files that declare a `closed` hierarchy are formatted like any other and the + `.csharpierignore` those entries lived in is gone. Do not reintroduce an ignore file to work around the parser; + a file it cannot read is a version bump, not an exclusion. - **Solutions**: `.slnx` solution files are used. - **Source generator**: targets `netstandard2.0` for broad compiler-host compatibility and uses `Purview.SourceGeneratorFramework` (CodeWriter-based emission, incremental pipeline, value-equatable @@ -222,6 +227,18 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel - **SDK**: projects import `Purview.BuildSdk` via `Directory.Build.props`/`.targets`. The repo sets `ExcludePurviewTelemetry=true` (it does not consume the telemetry package it generates) and `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/purview-buildsdk-*` for SDK behavior. +- **.NET 11 opt-ins are SDK settings, not repo settings.** `Purview.BuildSdk` 2.0.0 applies them to any + `net11.0`+ project from `Sdk/Targets/Modern.targets`: Runtime Async V2 (`PurviewRuntimeAsync=true`) and the + C# 15 updated memory-safety rules (`MemorySafetyRules=2`), with `AllowUnsafeBlocks` left off. Do not add + `Features` or `MemorySafetyRules` to a project file; set `PurviewRuntimeAsync=false` / `MemorySafetyRules=0` + to opt one project out. `.NET 10` and `netstandard2.0` legs are deliberately untouched. + `docs/wiki/Platform-Support.md` records every .NET 11 feature that was evaluated and why it is or is not used. +- **Dependency versions are lower bounds.** `Directory.Packages.props` keeps one property per package family — + `DotnetRuntimeVersion` for the runtime-tracked `10.0.` line, `MSExtensionVersion` for + dotnet/extensions' `10..0` — and pins the lowest supported framework line (`net10.0`), never the + `11.x` line; the `.NET 11` packages are used only by the `net11.0` packed consumers. `-p:DependencyFloor=true` + drops both families to the oldest version that compiles and passes, which `just test-floor` and the + `dependency-floor` PR job prove. Audit runs over the whole graph (`NuGetAuditMode=all`). - **C# 15 `closed` hierarchies**: the two tagged unions are closed. `Records/ActivityRecords.cs` declares `closed record ActivityMethodTarget` with `StartActivityMethod` / `ActivityEventMethod` / `ActivityContextMethod`, and `Records/MetricAttributeRecords.cs` declares `closed record InstrumentAttributeRecord` with one case per @@ -256,7 +273,17 @@ entry) are both in source, so a clone has them without running anything. | `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | | `purview-architecture`, `purview-architecture-testing`, `purview-engineering-principles`, `purview-persistence`, `purview-telemetry`, `purview-testing-conventions` | `purview-dev/engineering-playbook` | solution shape, architecture tests, engineering conventions, persistence, telemetry and testing conventions | -This repository's own skills in `skills/` are authored for **consumers** of its packages, not for use while working on this repository. They are never installed into this repository's `.agents/skills/`, which holds only skills installed from other repositories; `InstalledSkillTests` enforces this. +This repository's own skills in `skills/` are authored for **consumers** of its packages, not for use while working on this repository. They are never installed into this repository's `.agents/skills/`; `InstalledSkillTests` enforces this. + +`.agents/skills/` also holds the **developer skills**: skills written here for people working on the +generator, which are not consumer guidance and are never published. They are committed in place (the +skills CLI neither writes them nor records them in the lockfile), and `InstalledSkillTests` allows a +folder there only when it is declared in that class's `DeveloperSkills` list, which also asserts the +skill cannot drift into `skills/`. + +| Developer skill | Answers | +| --- | --- | +| `.agents/skills/purview-telemetry-benchmarks/` | running the BenchmarkDotNet suite for .NET 10 / .NET 11 / .NET Framework 4.8, reading a report, refreshing `docs/wiki/Performance.md` and `README.md`, writing the analysis, adding a family | Skills are installed into the universal `.agents/skills/` location **only**. Never install copies into `.claude/` or any other per-agent folder. Pass `-a universal`, which writes `.agents/skills/` @@ -273,12 +300,14 @@ npx -y skills@1 experimental_install ``` Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. -Do not edit anything under `.agents/skills/` by hand — the CLI is its only writer, and a hand edit -no longer matches the lockfile's hash. This repository's *own* skills are never installed here; they -live in `skills/` (below). `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to the same -CLI; `src/Directory.Build.props` sets `PurviewSkillsAgents=universal` so it is restricted the same -way rather than defaulting to every agent (`*`). None of the restored packages ship skills, so -`ListPurviewSkills` has nothing to recommend and the set above is chosen by hand. +Do not edit an *installed* skill under `.agents/skills/` by hand — the CLI is its only writer, and a +hand edit no longer matches the lockfile's hash. The developer skills above are the exception: nothing +installs them, so they are edited in place like any other source file. This repository's *own* consumer +skills are never installed here; they live in `skills/` (below). `Purview.BuildSdk`'s +`InstallPurviewSkills` target delegates to the same CLI; `src/Directory.Build.props` sets +`PurviewSkillsAgents=universal` so it is restricted the same way rather than defaulting to every agent +(`*`). None of the restored packages ship skills, so `ListPurviewSkills` has nothing to recommend and +the set above is chosen by hand. Claude Code is the exception: it reads skills from `.claude/skills/`, not `.agents/skills/`. Give it **links**, never copies, so there is still one installed copy and the lockfile stays its record. @@ -303,8 +332,9 @@ To remove a link, delete the link itself (`[System.IO.Directory]::Delete($link)` `Remove-Item -Recurse`, which can follow a junction into `.agents/skills/`. `src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` -entry has its `.agents/skills//SKILL.md`, every installed folder is in the lockfile, and every -entry in `.claude/skills/`, when the folder exists, is a link to the same skill in `.agents/skills/`. +entry has its `.agents/skills//SKILL.md`, every installed folder is in the lockfile or is one of +the `DeveloperSkills` above, and every entry in `.claude/skills/`, when the folder exists, is a link to +the same skill in `.agents/skills/`. ## Authoring skills @@ -350,9 +380,22 @@ prose instead, and keep deep detail in the skill's own `references/`. > `README.md`. Never copy stale results or estimate values — every performance table must come from a > current run. -- Run with `just benchmark` or directly: - `dotnet run --project ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj --configuration Release --framework net10.0` +The full workflow — running the suite, reading a report, refreshing `docs/wiki/Performance.md` and +`README.md`, writing the analysis and adding a family — is the developer skill +`.agents/skills/purview-telemetry-benchmarks/`. Load it before touching a performance number; the +points below are the constraints it depends on. + +- Run with `just benchmark`, or directly: + `dotnet run --project ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj --configuration Release --framework net11.0` - Results land in `BenchmarkDotNet.Artifacts/results/` as `*-report-github.md`, `*.csv`, `*.html`. + The folder is gitignored, so nothing links to it as a published artifact. +- **Every job names its runtime explicitly** (`RuntimeMoniker.Net10_0`, `Net11_0`, `Net48`), so one invocation + measures all three and one report carries all three. `RuntimeMoniker.Net11_0` exists only from BenchmarkDotNet + **0.16.0-preview.2** — 0.15.8's `RuntimeMoniker` stops at `Net10_0` — which is why the benchmark project pins a + prerelease. A host-runtime `[SimpleJob]` is not a substitute: it carries no Runtime characteristic and fails + BenchmarkDotNet's `GetSupportedBenchmarks` validation instead of measuring anything. +- `RuntimeMoniker.Net47` is deliberately absent: its generated host project fails to restore on every run, which + only ever produced `NA` rows. .NET Framework 4.8 is the supported .NET Framework target. - `just benchmark-docs` runs the suite and prints the docs-update checklist. - Observable instruments (`ObservableCounter`/`ObservableGauge`/`ObservableUpDownCounter`) are not benchmarked: they are registered once via a callback with no per-operation hot path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 583d4a63..0f0343c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,50 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). ### Added +- **`Activity.AddException` is used when the compilation can resolve it.** The generated exception helper now calls + `activity.AddException(exception, tags, timestamp)` so the framework produces the canonical `exception` event and + writes `exception.type`, `exception.message` and `exception.stacktrace` itself; only `exception.escaped` is supplied, + through the `TagList` the overload accepts, so nothing is duplicated. The probe is on the symbol + (`Activity.GetMembers("AddException")`), never the target framework, and the hand-built `ActivityEvent` remains as the + fallback so `net48` and older DiagnosticSource keep working unchanged. +- **`[LogProperties(Transitive = true)]`.** Nested properties are flattened into dotted tags (`weather.Location.City`), + mirroring Microsoft's `Microsoft.Gen.Logging` generator: public instance properties with a public getter, base types + walked, indexers/statics/delegates skipped, `[LogPropertyIgnore]` and `SkipNullProperties` honoured at **every** level, + non-nullable struct hops written with a plain dot, and a 32-type chain bound equal to the .NET generator's + `MaxTypeTraversalDepth`. A cycle or an over-deep chain stops that branch instead of overflowing the compiler. Two + documented divergences: a cycle stops one branch rather than abandoning the whole parameter, and a `Nullable` + property is not expanded further. +- **`[TagProvider]`.** `[TagProvider(typeof(T), "Method")]` on a logging parameter adds the pairs a static provider + method returns to the log state, prefixed with the parameter name unless `OmitReferenceName` is set. The generator + reads Microsoft's `Microsoft.Extensions.Logging.TagProviderAttribute` rather than shipping a duplicate, which would + make every `[TagProvider]` ambiguous (CS0104). Accepts `IEnumerable>`, the `string` + variant, and any type implementing one of them. New rules `TSG2024`–`TSG2027`. A provider parameter makes the method + state-based even when `V1` was requested, because `LoggerMessage.Define` has no tag bag. +- **Generic methods across all three targets.** A generic logging, activity or metrics method is generated with the + interface method's type parameters and constraints mirrored (`struct`, `class`, `class?`, `notnull`, `unmanaged`, + `new()`, type-parameter and type constraints). A generic logging method always uses the state-based path, since + `LoggerMessage.Define` cannot carry an open generic. `TSG1005` now reports only what genuinely cannot be generated and + says which: the `allows ref struct` anti-constraint, or a metric measurement that is one of the method's own type + parameters (an instrument is a field, and a field cannot name a method's type parameter). +- **Generic interfaces.** The generated class becomes generic over the interface's type parameters with the same + constraints, and the DI extension registers the open generic (`services.AddSingleton(typeof(IFoo<>), typeof(Foo<>))`). + `TSG1004` now reports only a nested generic interface or the `allows ref struct` anti-constraint. +- **`GeneratorModelBenchmarks`.** Compares the generator's `closed record` tagged models against a value-based + `[Union]` struct on every benchmark runtime leg. It backs the recorded decision to keep the `closed` records: the + measurable win is a fraction of a generation pass, and on .NET Framework the struct union is slower. See + [Platform Support](docs/wiki/Platform-Support.md#considered-and-rejected). +- **`[Meter]` / `[MeterGeneration]` `Version` and `Tags`.** `MeterOptions.Version` and `MeterOptions.Tags` were + previously reachable only through a `partial` hook the generator declared and never implemented, and `Version` was + hard-coded `null`. Both are now attribute settings (comma-separated `key=value` tags, interface-level overriding + assembly-level per key), and without an `IMeterFactory` the version goes to the `Meter(name, version)` constructor. + `MeterOptions.Scope` remains unreachable: it takes an object instance an attribute cannot express. +- **`[Log(SkipEnabledCheck = true)]`**, and the same property on every level attribute (`[Info]`, `[Error]`, …). The + generated `IsEnabled` guard is omitted, for callers that have already checked the level — the same contract as + `[LoggerMessage(SkipEnabledCheck = true)]`. Saves a virtual dispatch per log entry on a hot path. +- **`TSG2022` and `TSG2023`**, parity with the .NET 11 `[LoggerMessage]` generator's `SYSLIB1028`/`SYSLIB1029`. A + `params` parameter, or a `ref`/`out`/`in`/`scoped`/ref-struct parameter, is reported instead of being silently + re-declared without its modifier; the method is carried as invalid and generates a throw stub. + - **`[Gauge]` and `HistogramBucketBoundaries`.** A `[Gauge]` method generates the .NET 9 `Gauge` instrument, which records a value instead of accumulating one. `[Histogram(HistogramBucketBoundaries = "0.005, 0.01, 0.1")]` passes the bucket boundaries to `InstrumentAdvice`. Both are gated on the compilation: `TSG4010` reports a gauge without @@ -28,9 +72,26 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). `bool TryGetValue(out T)` overload per case) the accessor calls those, so the union struct is never boxed to read it; otherwise it reads `Value`. A union that holds no value records `null`. See [Union types](docs/wiki/Tags-and-Baggage.md#union-types). +- **A lowest-compatible dependency floor, proved by a build.** `Directory.Packages.props` now keeps one property per + package family (`DotnetRuntimeVersion` for the runtime-tracked `10.0.` line, `MSExtensionVersion` for + dotnet/extensions' `10..0`) plus a `DependencyFloor` switch. `-p:DependencyFloor=true` drops both families to + `10.0.0`, the oldest version the generator's code and tests compile and pass against, and `just test-floor` plus a + `dependency-floor` PR job prove it. Every declared version is a lower bound, so the floor declared here becomes the + floor every consumer inherits; `ReleaseGateTests` fails when the recipe or the job is removed. ### Changed +- **`TSG1004` and `TSG1005` no longer reject generics wholesale.** Generic interfaces and generic methods are now + generated (see Added); both rules report only the shapes that cannot be, and their messages name the shape. This is a + wording change to a shipped rule, not a new one. +- **`BenchmarkHelpers` and `SampleApp.Net48` follow the compilation-probed `IMeterFactory`.** Both still branched on the + target framework (`#if NETFRAMEWORK`, and a container that only called `AddLogging`), which the move to a symbol probe + had invalidated: the `net48` benchmark build no longer compiled, and the `net48` sample built but threw + `Unable to resolve service for type 'System.Diagnostics.Metrics.IMeterFactory'` on first resolution. The benchmark now + passes a factory on every leg, and the sample registers one with `services.AddMetrics()` (plus + `[assembly: MeterGeneration("sample-weather-app-net48")]` so its documented meter name is the one reported). + `docs/wiki/Metrics.md` and the [support matrix](docs/wiki/Platform-Support.md#support-matrix) said "not available on + .NET Framework 4.8"; they now describe the probe instead of a framework. - **The generator's two tagged unions are C# 15 `closed` hierarchies.** An activity method is exactly one of `StartActivityMethod`, `ActivityEventMethod` or `ActivityContextMethod`, and an instrument is exactly one of the eight `InstrumentAttributeRecord` cases, each carrying its own payload, creation method and instrument type. A method that @@ -45,6 +106,12 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). `net10.0` is the lowest target this generator supports. `net11.0` consumers still get the `11.0.x` packages when they reference them themselves. - **`Microsoft.SourceLink.GitHub` moves to `11.0.100`** to match the .NET 11 SDK the repository pins. +- **NuGet audits the whole dependency graph.** `NuGetAuditMode=all` replaces the SDK default of `direct`, so a + vulnerable transitive dependency is reported rather than only a directly declared one. This package ships a + compiler-host assembly, so a transitive dependency is one it hands to every consumer's build. +- **`.NET 11` opt-ins are the ones `Purview.BuildSdk` 2.0.0 already applies**, not new project properties: Runtime Async + V2 (`PurviewRuntimeAsync`) and the C# 15 updated memory-safety rules (`MemorySafetyRules=2`) for every `net11.0` + project. `docs/wiki/Platform-Support.md` records each .NET 11 feature that was evaluated and why it is or is not used. - **`EventId` on `[Log]` and the level-specific attributes is `int`, not `int?`.** A nullable property cannot be set as a named attribute argument, so `[Info(EventId = 42)]` failed with CS0655 and only the constructor overloads worked. Omitting it, or passing a negative value, still uses the id derived @@ -52,6 +119,21 @@ and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). ### Fixed +- **Purview.Unions `[GenerateUnion]` unions used as telemetry parameters are unwrapped to their case value.** Union + recognition only looked for `[System.Runtime.CompilerServices.Union]`, which the unions generator emits into + *generated* output — output Roslyn never shows another generator — so a `[GenerateUnion]` parameter was recorded as the + union struct instead of its case value. `UnionHelpers` now also recognises a type carrying + `[Purview.Unions.GenerateUnion]`, matched by metadata name and namespace so no package reference is taken, and reads + the case list from the surfaces that are in source, in the unions generator's own order: the attribute's `typeof` + list, the union's partial static factory signatures, a nested `IUnionMembers` provider, then its source-visible public + single-parameter constructors. Cases are normalised exactly as that generator exposes them (`int?` → `int`, nullable + annotation stripped, constructed generics and arrays preserved). The generated accessor always uses the non-boxing + `HasValue`/`TryGetValue` pattern for a `[GenerateUnion]` target, because that generator emits it for every surface; + a hand-written `[Union]` type keeps the visible-member detection and a `union` declaration still uses `Value`. A + `[GenerateUnion]` target with no source-visible case list is recorded as an ordinary value and produces no diagnostic + of its own — Purview.Unions reports `UNI0003` for it. The end-to-end test running both generators in one compilation + is a follow-up: Purview.Unions is not on a CI-safe feed yet, so the tests declare a stand-in attribute in source. + The level-specific logging attributes honour their arguments again. Generated log messages and event ids change for every method that passed a template, event id, name or generation mode to one of them. diff --git a/Directory.Packages.props b/Directory.Packages.props index 990193d8..fc8d897c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,19 +1,41 @@ - + 5.9.0 - 1.81.16 + 1.83.0 2.0.0 + 1.20.1 - 10.0.15 - 10.11.0 - 1.20.1 + false + 10.0.15 + 10.0.0 + 10.11.0 + 10.0.0 + + + + all diff --git a/Justfile b/Justfile index d676b383..7154c42f 100644 --- a/Justfile +++ b/Justfile @@ -123,6 +123,18 @@ test filter="/*/*/*/*/" *args: echo "Running tests for {{ BLUE }}{{ test_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module {{ args }} +# Runs the test solution against every declared dependency floor (`DependencyFloor=true`), proving the +# lowest common .NET 10 versions this package declares still compile and pass. The ordinary `test` +# recipe resolves the latest servicing patch of the same line, which is what a consumer actually gets. +# +# `Directory.Packages.props` holds one property per package family, so the floor is a switch rather than +# a second file, and this is the leg that proves it. `ReleaseGateTests` keeps the recipe and the PR job +# wired up, so a declared floor cannot drift away from the version that was tested. +[group('Build and Test')] +test-floor filter="/*/*/*/*/" *args: + echo "Running tests against the dependency floor for {{ test_solution }}..." + dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module -p:DependencyFloor=true {{ args }} + # Scan build or publish output for compiler-host assemblies (the generator, the refactorings, # Purview.SourceGeneratorFramework, Roslyn) that must never reach an application. [group('Build and Test')] @@ -262,14 +274,18 @@ scrub: # Benchmarking # ----------------------------------------------------------------------------- -# Runs the full benchmark suite - +# Runs the full benchmark suite: .NET 10, .NET 11 and .NET Framework 4.8 in one invocation. +# +# Release is fixed rather than taken from `build_configuration`: BenchmarkDotNet numbers from a Debug build +# are not meaningful. Every job names its runtime explicitly (`RuntimeMoniker.Net10_0`, `Net11_0`, `Net48`), +# so one report carries all three runtimes and the launch framework only selects the BenchmarkDotNet host. +# `Net11_0` needs BenchmarkDotNet 0.16.0-preview.2 or later; there is no stable release with it yet. [group('Benchmarking')] benchmark: - echo "Running benchmarks for {{ BLUE }}{{ benchmark_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." - dotnet run --project "{{ benchmark_solution }}" --configuration "{{ build_configuration }}" --framework net10.0 + echo "Running benchmarks for {{ BLUE }}{{ benchmark_solution }}{{ NORMAL }} with {{ YELLOW }}Release{{ NORMAL }}..." + dotnet run --project "{{ benchmark_solution }}" --configuration Release --framework net11.0 -- --artifacts BenchmarkDotNet.Artifacts -# Runs a quick single-runtime (.NET 10.0) benchmark with reduced iterations +# Runs a quick single-runtime (.NET 11) benchmark with reduced iterations # for development validation. # # Uses BenchmarkDotNet's short job: @@ -277,12 +293,12 @@ benchmark: # - 3 warmups # - 3 iterations # -# Use `just benchmark` for the full multi-runtime suite. - +# Its own artifacts folder keeps it from overwriting the full suite's report. +# Use `just benchmark` for the full three-runtime suite. [group('Benchmarking')] benchmark-quick: - echo "Running quick benchmarks (net10.0, short job) for {{ BLUE }}{{ benchmark_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." - dotnet run --project "{{ benchmark_solution }}" --configuration "{{ build_configuration }}" --framework net10.0 -- --job short --runtimes net10.0 + echo "Running quick benchmarks (net11.0, short job) for {{ BLUE }}{{ benchmark_solution }}{{ NORMAL }} with {{ YELLOW }}Release{{ NORMAL }}..." + dotnet run --project "{{ benchmark_solution }}" --configuration Release --framework net11.0 -- --job short --runtimes net11.0 --artifacts BenchmarkDotNet.Artifacts/quick # Runs benchmarks and reminds you to update performance documentation @@ -292,12 +308,12 @@ benchmark-docs: benchmark echo "{{ GREEN }}Benchmarks complete.{{ NORMAL }} Results are in {{ BLUE }}BenchmarkDotNet.Artifacts/results/{{ NORMAL }}." echo "" echo "{{ YELLOW }}Next steps - update performance documentation:{{ NORMAL }}" - echo " 1. Open {{ BLUE }}README.md{{ NORMAL }} and update the {{ YELLOW }}## Performance{{ NORMAL }} section:" + echo " 1. Update the {{ YELLOW }}## Performance{{ NORMAL }} section of {{ BLUE }}README.md{{ NORMAL }}:" echo " - Activities: use {{ BLUE }}*ActivityBenchmarks*-report-github.md{{ NORMAL }}" echo " - Logging: use {{ BLUE }}*LoggerBenchmarks*-report-github.md{{ NORMAL }}" echo " - Multi-target: use {{ BLUE }}*MultiTarget*-report-github.md{{ NORMAL }}" echo " - Metrics: use {{ BLUE }}*MetricsBenchmarks*-report-github.md{{ NORMAL }}" - echo " 2. Regenerate {{ BLUE }}PERFORMANCE.md{{ NORMAL }} from all six *-report-github.md files." - echo " 3. Update the environment header (machine / SDK / runtime versions)." + echo " 2. Regenerate {{ BLUE }}docs/wiki/Performance.md{{ NORMAL }} from all six *-report-github.md files, one section per family with a row per runtime." + echo " 3. Update the environment header (machine / SDK / runtime versions) in both files." echo "" - echo " See {{ BLUE }}.github/copilot-instructions.md{{ NORMAL }} - Benchmarking for full instructions." + echo " See {{ BLUE }}AGENTS.md{{ NORMAL }} - Benchmarking and performance documentation for the full rules." diff --git a/README.md b/README.md index d108b294..7eb81e71 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,10 @@ assembly reaches your application's output. See - **C# 15 static interface members** are ignored rather than implemented. - **C# 15 union types** are unwrapped to their case value for tags, baggage, log values and metric tags. A union that declares the non-boxing `TryGetValue`/`HasValue` pattern is read without boxing the union - struct. See [Union types](https://purview.dev/docs/telemetry-sourcegenerator/tags-and-baggage/#union-types). + struct. **Purview.Unions `[GenerateUnion]` types** work too: the case list is read from the surface that is + in source — the attribute's `typeof` list, partial static factories, a nested `IUnionMembers` provider, then + the constructors — so no reference to that package is taken and no other generator's output is needed. See + [Union types](https://purview.dev/docs/telemetry-sourcegenerator/tags-and-baggage/#union-types). - **Validated on .NET 11**: the tests run on `net11.0`, and Native AOT, Blazor WebAssembly and Blazor Server consumers of the package are built on every release. - **.NET 8 and .NET 9 are no longer supported**: target `net10.0`+ or `net48`+. `TSG1011` reports a diff --git a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs index fbdae414..1f03a236 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs @@ -48,13 +48,9 @@ public static (IMultiTargetTelemetry Generated, ManualMultiTargetTelemetry Manua var logger = CreateEnabledLogger(); var meterFactory = new SimpleMeterFactory(); -#if NETFRAMEWORK - // IMeterFactory is a .NET 8+ host concept, so on .NET Framework the generator emits a - // constructor without it and creates the Meter itself. - var generated = new MultiTargetTelemetryCore(logger); -#else + // IMeterFactory is probed on the compilation, not the target framework, so every leg - + // including .NET Framework - gets the injected-factory constructor. var generated = new MultiTargetTelemetryCore(logger, meterFactory); -#endif var manual = new ManualMultiTargetTelemetry(logger, meterFactory); return (generated, manual); @@ -65,14 +61,9 @@ public static (IMultiTargetTelemetry Generated, ManualMultiTargetTelemetry Manua /// public static (IMetricsFewTagsTelemetry FewTags, IMetricsManyTagsTelemetry ManyTags) CreateMetricsTelemetry() { -#if NETFRAMEWORK - var fewTags = new MetricsFewTagsTelemetryCore(); - var manyTags = new MetricsManyTagsTelemetryCore(); -#else var meterFactory = new SimpleMeterFactory(); var fewTags = new MetricsFewTagsTelemetryCore(meterFactory); var manyTags = new MetricsManyTagsTelemetryCore(meterFactory); -#endif return (fewTags, manyTags); } @@ -87,11 +78,7 @@ ManualMetricsTelemetry Manual ) CreateMetricsBenchmarkTelemetry() { var meterFactory = new SimpleMeterFactory(); -#if NETFRAMEWORK - var generated = new MetricsBenchmarkTelemetryCore(); -#else var generated = new MetricsBenchmarkTelemetryCore(meterFactory); -#endif var manual = new ManualMetricsTelemetry(meterFactory); return (generated, manual); } @@ -151,13 +138,8 @@ ManualMultiTargetTelemetry Manual var loggerManual = CreateEnabledLogger(); var meterFactory = new SimpleMeterFactory(); -#if NETFRAMEWORK - var generatedV2 = new MultiTargetTelemetryCore(loggerV2); - var generatedV1 = new MultiTargetV1TelemetryCore(loggerV1); -#else var generatedV2 = new MultiTargetTelemetryCore(loggerV2, meterFactory); var generatedV1 = new MultiTargetV1TelemetryCore(loggerV1, meterFactory); -#endif var manual = new ManualMultiTargetTelemetry(loggerManual, meterFactory); return (generatedV2, generatedV1, manual); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs index cae44b31..3758d0be 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/ActivityBenchmarks.cs @@ -17,9 +17,9 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; /// [MemoryDiagnoser] [HideColumns("Error", "StdDev", "Median", "RatioSD")] -[SimpleJob(RuntimeMoniker.Net47)] -[SimpleJob(RuntimeMoniker.Net48)] [SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] public class ActivityBenchmarks { ActivityListener? _listener; diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/GeneratorModelBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/GeneratorModelBenchmarks.cs new file mode 100644 index 00000000..7026142a --- /dev/null +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/GeneratorModelBenchmarks.cs @@ -0,0 +1,288 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace Purview.Telemetry.Benchmarks.Benchmarks; + +/// +/// Measures the cost of the two modelling strategies the generator's pipeline can use for the tagged hierarchies +/// (closed record InstrumentAttributeRecord / ActivityMethodTarget). +/// +/// +/// The generator's own models are internal to the analyzer assembly, which a benchmark project cannot reference, so the +/// two shapes are replicated here exactly as the generator declares them: an abstract record with one sealed case per +/// instrument and virtual properties, and a readonly struct carrying a tag plus the case payloads with a +/// non-boxing TryGetValue accessor. Both build the same number of models and read the same four facts off each +/// one, which is what a generation pass does per method. +/// +/// The result answers one question: is the per-method allocation and virtual dispatch of the record hierarchy a +/// measurable part of a generation pass? If the delta is a handful of nanoseconds per method, replacing the records +/// cannot move generator throughput, because a pass also parses every symbol, builds the message templates and writes +/// the source text. +/// +/// +[MemoryDiagnoser] +[HideColumns("Error", "StdDev", "Median", "RatioSD")] +[SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] +public class GeneratorModelBenchmarks +{ + [Params(8, 64)] + public int MethodCount { get; set; } + + [Benchmark(Baseline = true, Description = "closed record hierarchy: build + dispatch")] + public int ClosedRecords_BuildAndRead() + { + List models = new(MethodCount); + for (var i = 0; i < MethodCount; i++) + { + models.Add( + (i % 7) switch + { + 0 => new CounterRecord("name", "unit", "description", AutoIncrement: true), + 1 => new UpDownCounterRecord("name", "unit", "description"), + 2 => new HistogramRecord("name", "unit", "description"), + 3 => new GaugeRecord("name", "unit", "description"), + 4 => new ObservableCounterRecord("name", "unit", "description", ThrowOnAlreadyInitialized: false), + 5 => new ObservableGaugeRecord("name", "unit", "description", ThrowOnAlreadyInitialized: false), + _ => new ObservableUpDownCounterRecord( + "name", + "unit", + "description", + ThrowOnAlreadyInitialized: false + ), + } + ); + } + + var total = 0; + foreach (var model in models) + { + total += model.CreationMethodName.Length; + total += model.TypeName.Length; + total += model.IsObservable ? 1 : 0; + total += model.UsesRecordMethod ? 1 : 0; + } + + return total; + } + + [Benchmark(Description = "value-based struct union: build + dispatch")] + public int StructUnion_BuildAndRead() + { + List models = new(MethodCount); + for (var i = 0; i < MethodCount; i++) + { + models.Add( + (i % 7) switch + { + 0 => InstrumentUnion.ForCounter(new("name", "unit", "description", true, false)), + 1 => InstrumentUnion.ForUpDownCounter(new("name", "unit", "description", false, false)), + 2 => InstrumentUnion.ForHistogram(new("name", "unit", "description", false, false)), + 3 => InstrumentUnion.ForGauge(new("name", "unit", "description", false, false)), + 4 => InstrumentUnion.ForObservableCounter(new("name", "unit", "description", false, false)), + 5 => InstrumentUnion.ForObservableGauge(new("name", "unit", "description", false, false)), + _ => InstrumentUnion.ForObservableUpDownCounter(new("name", "unit", "description", false, false)), + } + ); + } + + var total = 0; + foreach (var model in models) + { + total += model.CreationMethodName.Length; + total += model.TypeName.Length; + total += model.IsObservable ? 1 : 0; + total += model.UsesRecordMethod ? 1 : 0; + } + + return total; + } +} + +internal abstract record InstrumentRecord( + string? Name, + string? Unit, + string? Description, + bool AutoIncrement, + bool ThrowOnAlreadyInitialized +) +{ + public abstract string CreationMethodName { get; } + + public abstract string TypeName { get; } + + public abstract bool IsObservable { get; } + + public abstract bool UsesRecordMethod { get; } +} + +internal sealed record CounterRecord(string? Name, string? Unit, string? Description, bool AutoIncrement) + : InstrumentRecord(Name, Unit, Description, AutoIncrement, false) +{ + public override string CreationMethodName => "CreateCounter"; + + public override string TypeName => "Counter"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => false; +} + +internal sealed record UpDownCounterRecord(string? Name, string? Unit, string? Description) + : InstrumentRecord(Name, Unit, Description, false, false) +{ + public override string CreationMethodName => "CreateUpDownCounter"; + + public override string TypeName => "UpDownCounter"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => false; +} + +internal sealed record HistogramRecord(string? Name, string? Unit, string? Description) + : InstrumentRecord(Name, Unit, Description, false, false) +{ + public override string CreationMethodName => "CreateHistogram"; + + public override string TypeName => "Histogram"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => true; +} + +internal sealed record GaugeRecord(string? Name, string? Unit, string? Description) + : InstrumentRecord(Name, Unit, Description, false, false) +{ + public override string CreationMethodName => "CreateGauge"; + + public override string TypeName => "Gauge"; + + public override bool IsObservable => false; + + public override bool UsesRecordMethod => true; +} + +internal sealed record ObservableCounterRecord( + string? Name, + string? Unit, + string? Description, + bool ThrowOnAlreadyInitialized +) : InstrumentRecord(Name, Unit, Description, false, ThrowOnAlreadyInitialized) +{ + public override string CreationMethodName => "CreateObservableCounter"; + + public override string TypeName => "ObservableCounter"; + + public override bool IsObservable => true; + + public override bool UsesRecordMethod => false; +} + +internal sealed record ObservableGaugeRecord( + string? Name, + string? Unit, + string? Description, + bool ThrowOnAlreadyInitialized +) : InstrumentRecord(Name, Unit, Description, false, ThrowOnAlreadyInitialized) +{ + public override string CreationMethodName => "CreateObservableGauge"; + + public override string TypeName => "ObservableGauge"; + + public override bool IsObservable => true; + + public override bool UsesRecordMethod => false; +} + +internal sealed record ObservableUpDownCounterRecord( + string? Name, + string? Unit, + string? Description, + bool ThrowOnAlreadyInitialized +) : InstrumentRecord(Name, Unit, Description, false, ThrowOnAlreadyInitialized) +{ + public override string CreationMethodName => "CreateObservableUpDownCounter"; + + public override string TypeName => "ObservableUpDownCounter"; + + public override bool IsObservable => true; + + public override bool UsesRecordMethod => false; +} + +// --- The value-based union equivalent: tag plus payload, no boxing, value equality. --- + +internal enum InstrumentKind +{ + Counter, + UpDownCounter, + Histogram, + Gauge, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, +} + +internal readonly record struct InstrumentPayload( + string? Name, + string? Unit, + string? Description, + bool AutoIncrement, + bool ThrowOnAlreadyInitialized +); + +internal readonly record struct InstrumentUnion(InstrumentKind Kind, InstrumentPayload Payload) +{ + public static InstrumentUnion ForCounter(InstrumentPayload payload) => new(InstrumentKind.Counter, payload); + + public static InstrumentUnion ForUpDownCounter(InstrumentPayload payload) => + new(InstrumentKind.UpDownCounter, payload); + + public static InstrumentUnion ForHistogram(InstrumentPayload payload) => new(InstrumentKind.Histogram, payload); + + public static InstrumentUnion ForGauge(InstrumentPayload payload) => new(InstrumentKind.Gauge, payload); + + public static InstrumentUnion ForObservableCounter(InstrumentPayload payload) => + new(InstrumentKind.ObservableCounter, payload); + + public static InstrumentUnion ForObservableGauge(InstrumentPayload payload) => + new(InstrumentKind.ObservableGauge, payload); + + public static InstrumentUnion ForObservableUpDownCounter(InstrumentPayload payload) => + new(InstrumentKind.ObservableUpDownCounter, payload); + + public string CreationMethodName => + Kind switch + { + InstrumentKind.Counter => "CreateCounter", + InstrumentKind.UpDownCounter => "CreateUpDownCounter", + InstrumentKind.Histogram => "CreateHistogram", + InstrumentKind.Gauge => "CreateGauge", + InstrumentKind.ObservableCounter => "CreateObservableCounter", + InstrumentKind.ObservableGauge => "CreateObservableGauge", + _ => "CreateObservableUpDownCounter", + }; + + public string TypeName => + Kind switch + { + InstrumentKind.Counter => "Counter", + InstrumentKind.UpDownCounter => "UpDownCounter", + InstrumentKind.Histogram => "Histogram", + InstrumentKind.Gauge => "Gauge", + InstrumentKind.ObservableCounter => "ObservableCounter", + InstrumentKind.ObservableGauge => "ObservableGauge", + _ => "ObservableUpDownCounter", + }; + + public bool IsObservable => + Kind + is InstrumentKind.ObservableCounter + or InstrumentKind.ObservableGauge + or InstrumentKind.ObservableUpDownCounter; + + public bool UsesRecordMethod => Kind is InstrumentKind.Histogram or InstrumentKind.Gauge; +} diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs index 16d0aeda..5b039c60 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs @@ -24,9 +24,9 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; /// [MemoryDiagnoser] [HideColumns("Error", "StdDev", "Median", "RatioSD")] -[SimpleJob(RuntimeMoniker.Net47)] -[SimpleJob(RuntimeMoniker.Net48)] [SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] public class LoggerBenchmarks { ILoggerOnlyTelemetry _generatedV2 = default!; diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs index c7524d27..78339fce 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerMultiTargetBenchmarks.cs @@ -24,9 +24,9 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; /// [MemoryDiagnoser] [HideColumns("Error", "StdDev", "Median", "RatioSD")] -[SimpleJob(RuntimeMoniker.Net47)] -[SimpleJob(RuntimeMoniker.Net48)] [SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] public class LoggerMultiTargetBenchmarks { ActivityListener? _listener; diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs index 4dfcd59b..cec51f58 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs @@ -20,9 +20,9 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; /// [MemoryDiagnoser] [HideColumns("Error", "StdDev", "Median", "RatioSD")] -[SimpleJob(RuntimeMoniker.Net47)] -[SimpleJob(RuntimeMoniker.Net48)] [SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] public class MetricsBenchmarks { IMetricsBenchmarkTelemetry _generated = default!; diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs index 0f6dbd34..d65daa4d 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MultiTargetVsSingleTargetBenchmarks.cs @@ -18,9 +18,9 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; /// [MemoryDiagnoser] [HideColumns("Error", "StdDev", "Median", "RatioSD")] -[SimpleJob(RuntimeMoniker.Net47)] -[SimpleJob(RuntimeMoniker.Net48)] [SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] public class MultiTargetVsSingleTargetBenchmarks { ActivityListener? _listener; diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs index 43e1bc31..5a0618e3 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/TagListBenchmarks.cs @@ -22,9 +22,9 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; /// [MemoryDiagnoser] [HideColumns("Error", "StdDev", "Median", "RatioSD")] -[SimpleJob(RuntimeMoniker.Net47)] -[SimpleJob(RuntimeMoniker.Net48)] [SimpleJob(RuntimeMoniker.Net10_0)] +[SimpleJob(RuntimeMoniker.Net11_0)] +[SimpleJob(RuntimeMoniker.Net48)] public class TagListBenchmarks { IMetricsFewTagsTelemetry _fewTags = default!; diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Net48Polyfills.cs b/benchmarks/Purview.Telemetry.Benchmarks/Net48Polyfills.cs new file mode 100644 index 00000000..9a3d2344 --- /dev/null +++ b/benchmarks/Purview.Telemetry.Benchmarks/Net48Polyfills.cs @@ -0,0 +1,10 @@ +#if NET48 +namespace System.Runtime.CompilerServices; + +/// +/// .NET Framework 4.8 has no IsExternalInit, which the compiler needs to emit a positional record's +/// init accessors. The benchmark suite deliberately includes a .NET Framework leg, so the shim the modern +/// framework supplies is declared here. +/// +internal static class IsExternalInit { } +#endif diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Program.cs b/benchmarks/Purview.Telemetry.Benchmarks/Program.cs index 62efcb53..bc7144fd 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Program.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Program.cs @@ -1,16 +1,20 @@ using BenchmarkDotNet.Running; using Purview.Telemetry.Benchmarks.Benchmarks; -// Run all benchmarks when no arguments are provided, or use BenchmarkSwitcher -// for interactive selection (pass --filter or class names via args). +// BenchmarkSwitcher.Run prompts for a selection when the arguments select no benchmark, and +// `--artifacts ` or `--job short` alone is exactly that: they configure the run, they do not choose +// what to run. Appending a `*` filter when nothing selects a benchmark keeps those options working and +// never prompts. A --filter or --list is passed through untouched, so filtering and listing keep working. var switcher = BenchmarkSwitcher.FromAssembly(typeof(ActivityBenchmarks).Assembly); -if (args.Length == 0) +if (args.Any(static arg => IsSelection(arg))) { - // Run all benchmarks when launched without arguments. - switcher.RunAll(); + switcher.Run(args); } else { - switcher.Run(args); + switcher.Run([.. args, "--filter", "*"]); } + +static bool IsSelection(string arg) => + arg is "--list" or "--filter" || arg.StartsWith("--filter=", StringComparison.Ordinal); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj index e5608965..abefba3c 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj +++ b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj @@ -1,7 +1,11 @@ Exe - net48;net10.0 + + net48;net10.0;net11.0 14.0 enable enable @@ -49,7 +53,15 @@ - + + + diff --git a/benchmarks/README.md b/benchmarks/README.md index c479404d..7d4eb979 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -4,43 +4,78 @@ BenchmarkDotNet-based performance benchmarks comparing interface-based (source-g ## Overview -These benchmarks measure the performance and allocation characteristics of the Purview Telemetry Source Generator across five key dimensions: +These benchmarks measure the performance and allocation characteristics of the Purview Telemetry Source Generator across seven key dimensions: | Benchmark Class | What it measures | |---|---| | `ActivityBenchmarks` | Generated vs. manual Activity telemetry, with/without an `ActivityListener` | +| `GeneratorModelBenchmarks` | The generator's own `closed record` models vs. a value-based `[Union]` struct — contributor-facing, not a consumer hot path | | `LoggerBenchmarks` | Generated logging (v1/v2) vs. manual ILogger and LoggerMessage.Define, with/without logging enabled | | `LoggerMultiTargetBenchmarks` | Single-target (log only) vs. multi-target (Activity + Logging + Metrics) in v1/v2 modes | | `MultiTargetVsSingleTargetBenchmarks` | Multi-target (Activity + Log + Metric in one call) vs. single-target (Activity only) | | `TagListBenchmarks` | Metrics recording: few tags (inline `KeyValuePair` params) vs. many tags (`TagList` struct) | +| `MetricsBenchmarks` | Generated vs. hand-written `Counter`/`UpDownCounter`/`Histogram`, with 0 and 1 tag | + +Observable instruments (`ObservableCounter`, `ObservableGauge`, `ObservableUpDownCounter`) are not benchmarked at all: +they are registered once with a callback and observed by the metrics pipeline, so there is no per-operation hot path to +compare. + +## Runtimes and jobs + +Every benchmark class declares the same three jobs, each naming its runtime explicitly: + +| Job | Runtime | +| --- | --- | +| `[SimpleJob(RuntimeMoniker.Net10_0)]` | .NET 10 | +| `[SimpleJob(RuntimeMoniker.Net11_0)]` | .NET 11 | +| `[SimpleJob(RuntimeMoniker.Net48)]` | .NET Framework 4.8 — the `netstandard2.0` asset path, which is the only way to compare it against the modern runtimes. Windows only. | + +One invocation therefore measures all three runtimes, and one report carries all three sections. The framework +the suite is launched with only selects the BenchmarkDotNet host. + +`RuntimeMoniker.Net11_0` is why this project pins **BenchmarkDotNet `0.16.0-preview.2`**, a prerelease: the newest +stable release, 0.15.8, has `RuntimeMoniker` members only up to `Net10_0`. `Net12_0` is already present in +0.16.0-preview.2 as well, so the next framework needs no further dependency change. `RuntimeMoniker` is declared +in `BenchmarkDotNet.Annotations.dll`, so that is the assembly to inspect when checking which monikers a version has. + +`RuntimeMoniker.Net47` is deliberately absent: its generated host project fails to restore on every run, so it only +ever added `NA` rows and "Benchmarks with issues" noise to each report. .NET Framework 4.8 is the supported +.NET Framework target anyway. ## Running the Benchmarks ### Prerequisites -- .NET 10 SDK -- Release build (BenchmarkDotNet requires `Release` configuration) +- The .NET 11 SDK pinned in `global.json`, plus the .NET 10 and .NET 11 runtimes (each job measures its own + runtime, so both must be installed) +- Windows, for the .NET Framework 4.8 job +- Release build (BenchmarkDotNet requires `Release`; a Debug build's numbers are not meaningful) -### Run all benchmarks +### Run the full suite ```bash -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 ``` +`just benchmark` runs the same thing. One invocation measures .NET 10, .NET 11 and .NET Framework 4.8, and writes one +report per benchmark class to `BenchmarkDotNet.Artifacts/results/`. + ### Run a specific benchmark class ```bash -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release -- --filter "*ActivityBenchmarks*" -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release -- --filter "*LoggerBenchmarks*" -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release -- --filter "*LoggerMultiTarget*" -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release -- --filter "*MultiTarget*" -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release -- --filter "*TagList*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*ActivityBenchmarks*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*GeneratorModelBenchmarks*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*LoggerBenchmarks*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*LoggerMultiTarget*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*MultiTarget*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*TagList*" +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --filter "*MetricsBenchmarks*" ``` ### Interactive selection ```bash -dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release -- --list flat +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks --configuration Release --framework net11.0 -- --list flat ``` ## Benchmark Details @@ -54,6 +89,23 @@ Compares generated vs. manually-written activity telemetry with a parameterised This tests whether the generated code has any overhead compared to equivalent hand-written code, under both the hot path (listener present) and the cold-but-common path (no listener). +### `GeneratorModelBenchmarks` + +Compares the two modelling strategies the generator's pipeline can use for its tagged hierarchies +(`closed record InstrumentAttributeRecord` / `ActivityMethodTarget`): an abstract record with one sealed case per +instrument and virtual properties, versus a `readonly struct` union carrying a tag and the case payloads with a +non-boxing `TryGetValue` accessor. Both build the same number of models and read the same four facts off each one, +which is what a generation pass does per method; `MethodCount` is 8 and 64. + +The generator's models are internal to the analyzer assembly, so the two shapes are replicated in the benchmark +project rather than referenced — keep the replica faithful to `src/src/SourceGenerator/Records/` when either shape +changes. + +This family is **contributor-facing**: it answers whether the record hierarchy's per-method allocation and virtual +dispatch can move generator throughput, and it backs the recorded decision in +[Platform Support](../docs/wiki/Platform-Support.md#considered-and-rejected) to keep the `closed` records. It is not +a consumer hot path and is not part of the README's consumer performance tables. + ### `LoggerBenchmarks` Compares three logging implementations with a parameterised `HasLogging` flag: @@ -98,6 +150,21 @@ Demonstrates the two code paths the generator produces for metric tag recording: The threshold is 4 tags. This benchmark shows the performance difference between the two approaches. +### `MetricsBenchmarks` + +Compares the generated synchronous instruments against hand-written best-practice code, one instrument call per +benchmark with the manual implementation as the baseline: + +| Pair | Generated call | Manual call | +|---|---|---| +| Auto-counter, 0 tags | `IncrementRequestCount()` | `Counter.Add(1)` | +| Auto-counter, 1 tag | `CountRequestByType("read")` | `Counter.Add(1, KeyValuePair("request_type", "read"))` | +| Up-down counter | `AdjustActiveConnections(1)` | `UpDownCounter.Add(1)` | +| Histogram, 0 tags | `RecordDuration(42.0)` | `Histogram.Record(42.0)` | +| Histogram, 1 tag | `RecordDurationByEndpoint(42.0, "/api/data")` | `Histogram.Record(42.0, KeyValuePair("endpoint", "/api/data"))` | + +Observable instruments are absent for the reason given above: a callback registered once has no per-operation hot path. + ## Project Structure ``` @@ -105,24 +172,29 @@ benchmarks/ └── Purview.Telemetry.Benchmarks/ ├── Benchmarks/ │ ├── ActivityBenchmarks.cs # Activity: generated vs. manual, listener vs. no-listener + │ ├── GeneratorModelBenchmarks.cs # Generator models: closed records vs. value-based union │ ├── LoggerBenchmarks.cs # Logging: v1/v2 generated vs. manual, logging enabled/disabled │ ├── LoggerMultiTargetBenchmarks.cs # Logger single-target vs. multi-target (v1/v2/manual) + │ ├── MetricsBenchmarks.cs # Counter/UpDownCounter/Histogram: generated vs. manual │ ├── MultiTargetVsSingleTargetBenchmarks.cs # Multi-target vs. single-target │ └── TagListBenchmarks.cs # TagList vs. inline KVP metrics ├── Manual/ │ ├── ManualActivityTelemetry.cs # Hand-written Activity telemetry │ ├── ManualLoggerMessageTelemetry.cs # Hand-written LoggerMessage.Define logging │ ├── ManualLoggerTelemetry.cs # Hand-written direct ILogger.Log logging + │ ├── ManualMetricsTelemetry.cs # Hand-written Counter/UpDownCounter/Histogram │ └── ManualMultiTargetTelemetry.cs # Hand-written Activity + Log + Metric ├── Telemetry/ │ ├── IActivityOnlyTelemetry.cs # [ActivitySource] interface │ ├── ILoggerOnlyTelemetry.cs # [Logger] interface (v2 state-based) │ ├── ILoggerV1OnlyTelemetry.cs # [Logger(GenerationMode = V1)] (v1 LoggerMessage.Define) + │ ├── IMetricsBenchmarkTelemetry.cs # [Meter] interface, generated-vs-manual instrument pairs │ ├── IMetricsFewTagsTelemetry.cs # [Meter] interface, 0–3 tags per method │ ├── IMetricsManyTagsTelemetry.cs # [Meter] interface, 4–6 tags per method │ ├── IMultiTargetTelemetry.cs # [ActivitySource][Logger v2][Meter] interface │ └── IMultiTargetV1Telemetry.cs # [ActivitySource][Logger v1][Meter] interface ├── BenchmarkHelpers.cs # Factory helpers for setup + ├── Net48Polyfills.cs # IsExternalInit shim for the .NET Framework 4.8 leg └── Program.cs # Entry point ``` diff --git a/commitlint.config.mts b/commitlint.config.mts index 1d3240bf..eeaa416d 100644 --- a/commitlint.config.mts +++ b/commitlint.config.mts @@ -14,7 +14,7 @@ export default { 'type-enum': [ 2, 'always', - ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'], + ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'qol'], ], }, }; diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 58a3a228..f5442c8b 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -20,8 +20,8 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `TSG1001` | Error | Inferring generation targets is not supported when using multi-target generation. A method on a multi-target interface has no explicit generation attribute. | | `TSG1002` | Error | Multiple attributes from the same target family are not supported. Only one Activity, Logging, or Metrics attribute is allowed per method. | | `TSG1003` | Error | Duplicate method names are not supported. Two or more methods on the interface share the same name. | -| `TSG1004` | Error | Generic interfaces are not supported. | -| `TSG1005` | Error | Generic methods are not supported. | +| `TSG1004` | Error | Generic interface cannot be generated. A generic interface is generated as a generic class with the same type parameters and constraints; the shapes that cannot be are a **nested** generic interface (the enclosing type's type arguments cannot be repeated on the generated class) and a type parameter carrying the `allows ref struct` anti-constraint (the generated class cannot repeat it). | +| `TSG1005` | Error | Generic method cannot be generated. Generic methods and their constraints are mirrored; what cannot be is a type parameter carrying the `allows ref struct` anti-constraint, or a **metric** method whose measurement is one of its own type parameters (the instrument is a field, and a field cannot name a method's type parameter — declare that type parameter on the interface instead). The method is emitted as a throwing stub so the interface stays implemented. | | `TSG1006` | Warning | `ExcludeTargets` references a target not present on this method. | | `TSG1007` | Warning | `ExcludeTargets` results in an empty or invalid parameter set for a target. | | `TSG1008` | Warning | Activity parameter has no Activity target. A parameter of type `Activity` is present on a method with no `[Activity]`/`[Event]`/`[Context]` attribute. | @@ -42,6 +42,12 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `TSG2007` | Warning | A scoped log shouldn't have a `LogLevel`; it will be ignored. | | `TSG2008` | Warning | Unbounded enumeration possible. `[ExpandEnumerable]` has a `MaximumValueCount` greater than the recommended default of 5. | | `TSG2021` | Error | Log method must return void or IDisposable. (Returning `Activity` is allowed when the method is also an Activity method.) | +| `TSG2022` | Error | `params` parameters are not supported. The generated method re-declares the parameter without the modifier, so the `params` call form would stop compiling. Take an array and mark it `[ExpandEnumerable]` instead. Parity with the .NET 11 `[LoggerMessage]` generator's `SYSLIB1028`. | +| `TSG2023` | Error | `ref`, `out`, `in`, `scoped`, and ref struct parameters (such as `Span`) are not supported: the generated state struct cannot capture them. Parity with the .NET 11 `[LoggerMessage]` generator's `SYSLIB1029`. | +| `TSG2024` | Error | The `[TagProvider]` type could not be resolved. The parameter is logged without the provider's extra tags. | +| `TSG2025` | Error | The `[TagProvider]` method could not be found on the provider type. The parameter is logged without the provider's extra tags. | +| `TSG2026` | Error | The `[TagProvider]` method has an invalid signature. It must be a visible static method taking one parameter the annotated parameter can be passed to, and returning `IEnumerable>` (or the `string`-valued variant, or a type implementing one). The parameter is logged without the provider's extra tags. | +| `TSG2027` | Warning | `[TagProvider]` is on a method that generates no log entry, so the provider would never run. Move it to a logging method, or remove it. | ## Activities diagnostics (TSG3xxx) @@ -109,6 +115,56 @@ interface IMyTelemetry Two or more methods share the same name, which is used to generate members on the implementation class. Rename the methods. +### TSG1004 — generic interface cannot be generated + +A generic interface is generated as a generic class with the same type parameters and constraints: + +```csharp +[Logger] +public interface IMyTelemetry where T : class +{ + void Log(T value); // ✅ generated as IMyTelemetryCore +} +``` + +Two shapes still cannot be generated, because the generated class has no way to express them: + +- a **nested** generic interface — the enclosing type's type arguments cannot be repeated on the generated class; +- a type parameter carrying the `allows ref struct` anti-constraint — it cannot be repeated on the generated class. + +### TSG1005 — generic method cannot be generated + +Generic methods are generated with the interface method's type parameters and constraints mirrored onto the +implementation, and constraints are mirrored for `struct`, `class`, `class?`, `notnull`, `unmanaged`, `new()` and +type-parameter or type constraints: + +```csharp +[Logger] +public interface IMyTelemetry +{ + void Log(T value) where T : notnull; // ✅ generated as void Log(T value) where T : notnull +} +``` + +The method is emitted as a throwing stub, so the interface stays implemented, when: + +- a type parameter carries the `allows ref struct` anti-constraint; or +- a metric method uses one of its own type parameters as the instrument measurement. An instrument is a **field**, and + a field cannot name a method's type parameter — declare that type parameter on the interface instead, which makes the + generated class generic and lets the instrument field use it. + +### TSG2024 / TSG2025 / TSG2026 — `[TagProvider]` cannot be used + +`[TagProvider(typeof(T), "Method")]` names a static method that contributes extra tags; see +[Logging](Logging.md#tagprovider-contributed-tags). `TSG2024` reports a provider type that cannot be resolved, +`TSG2025` a method that does not exist on it, and `TSG2026` a method whose signature the generated `foreach` cannot +read. In every case the parameter is still logged — only the extra tags are missing. + +### TSG2027 — `[TagProvider]` on a method that never logs + +The attribute is on a parameter of a method that generates no log entry (for example an `[Activity]`-only method on a +multi-target interface). Move it to the logging method, or remove it. + ### TSG2008 — unbounded enumeration `[ExpandEnumerable]` is configured with a `MaximumValueCount` greater than 5. Reduce it to the recommended default (5) unless you have tested the performance impact. diff --git a/docs/wiki/Logging.md b/docs/wiki/Logging.md index 8def0f37..91f936a3 100644 --- a/docs/wiki/Logging.md +++ b/docs/wiki/Logging.md @@ -12,14 +12,21 @@ To signal an interface for logging generation, decorate it with the `[Logger]` a | Parameter limit | Maximum 6 non-exception parameters plus one optional `Exception` | No fixed parameter limit | | `[ExpandEnumerable]` | No effect | Supported | | `[LogProperties]` | No effect | Supported | +| `[TagProvider]` | No effect — the method is generated with v2 instead | Supported | +| A generic method | No effect — the method is generated with v2 instead | Supported | | Required reference | `Microsoft.Extensions.Logging` | `Microsoft.Extensions.Telemetry.Abstractions` | ### How `Auto` mode works The default `LoggerGenerationMode.Auto` selects the mode **per method**: -- **v1** is used when the method is within the v1 limits (≤ 6 non-exception parameters, at most one `Exception`, and no `[ExpandEnumerable]` or `[LogProperties]` parameters). -- **v2** is used when the method needs it (more than 6 parameters, `[ExpandEnumerable]`, or `[LogProperties]`), which requires `Microsoft.Extensions.Telemetry.Abstractions`. +- **v1** is used when the method is within the v1 limits (≤ 6 non-exception parameters, at most one `Exception`, and no `[ExpandEnumerable]`, `[LogProperties]` or `[TagProvider]` parameters, and the method is not generic). +- **v2** is used when the method needs it (more than 6 parameters, `[ExpandEnumerable]`, `[LogProperties]`, `[TagProvider]`, or a generic method), which requires `Microsoft.Extensions.Telemetry.Abstractions`. + +A `[TagProvider]` parameter is the one case that overrides an explicit `V1`: `LoggerMessage.Define` carries a fixed +parameter list and no tag bag, so the method is generated with the state-based path rather than silently dropping the +provider's tags. A generic method always uses the state-based path for the same reason — `LoggerMessage.Define` cannot +carry an open generic. You can force a mode with the `GenerationMode` property: @@ -68,6 +75,92 @@ interface IOrderServiceTelemetry When a method returns `IDisposable?`, the entry is emitted when the scope is created and the duration is logged when it is disposed. A scoped method should not specify an explicit level (`TSG2007` warns if it does, since the level is ignored). +## `[LogProperties(Transitive = true)]` + +`[LogProperties]` flattens the parameter's readable properties into individual tags. With `OmitReferenceName` off, each +tag is named `parameter.Property`; with it on, just `Property`. + +`Transitive = true` also flattens the properties of any property whose type is itself a complex object, producing +dotted names: + +```csharp +public class WeatherForecast +{ + public Location Location { get; set; } +} + +public class Location +{ + public string City { get; set; } +} + +[Logger] +interface IWeatherTelemetry +{ + void LogWeather([LogProperties(Transitive = true)] WeatherForecast weather); + // tags: weather.Location.City, ... +} +``` + +The walk mirrors Microsoft's `Microsoft.Gen.Logging` generator: + +- public instance properties with a public getter, walking base types, with a property hidden by a derived type logged + once; +- indexers, static properties and delegate-typed properties are skipped; +- `[LogPropertyIgnore]` is honoured at **every** level; +- `SkipNullProperties` is applied at **every** level; +- a non-nullable struct property is reached with a plain dot (`weather?.Location.City`); a nullable reference-typed + property adds a conditional access (`weather?.Location?.City`); +- primitives, strings, enums, collections and the well-known value-like types (`DateTime`, `DateTimeOffset`, `DateOnly`, + `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, `Version`) stay a single tag rendered with `ToString`. + +**Depth and cycles.** A property chain may reach 32 types, the same `MaxTypeTraversalDepth` the .NET 11 generator uses. +A type already on the chain (a self-reference, or two types that reference each other) and a chain that would exceed +the limit **stop flattening that branch**: the tags found so far are kept and the repeated type is not walked again. +The compiler cannot be made to overflow, and the parameter is still logged. + +Two deliberate divergences from the .NET 11 generator, both to keep the emitted code compiling: + +- Microsoft's generator abandons the *whole* `[LogProperties]` expansion when it sees a cycle; this generator stops only + the branch, so the parent's other properties are still logged. +- A `Nullable` property (a nullable *struct*) is flattened no further. Reaching its members needs a `.Value` hop that + the generated null-conditional chain cannot express, so it is logged as one tag. + +## `[TagProvider]` contributed tags + +`[TagProvider(typeof(T), "Method")]` on a logging parameter names a static method that contributes extra tags at run +time. The attribute is Microsoft's `Microsoft.Extensions.Logging.TagProviderAttribute`, from +`Microsoft.Extensions.Telemetry.Abstractions` — the generator reads that type rather than shipping a duplicate, because a +second `Purview.Telemetry.TagProviderAttribute` would make every `[TagProvider]` ambiguous (CS0104) in a project that +imports both namespaces for `ILogger` and `[Logger]`. + +```csharp +public static class WeatherTagProvider +{ + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; +} + +[Logger] +interface IWeatherTelemetry +{ + void LogWeather([TagProvider(typeof(WeatherTagProvider), nameof(WeatherTagProvider.GetTags))] WeatherForecast weather); + // tags: weather.Summary (the parameter name prefixes each contributed key) +} +``` + +- The provider method must be a visible static, non-generic method taking one parameter the annotated parameter can be + passed to, and returning `IEnumerable>` — the `string`-valued variant, and any type + implementing one of them (an array or `List<...>`), are accepted too. Anything else is `TSG2026`. +- `OmitReferenceName = true` leaves the contributed keys unprefixed. +- A null parameter contributes nothing (the call is guarded). +- **Divergence:** the .NET 11 collector shape (`static void GetTags(ITagCollector collector, T value)`) is not accepted + and is reported as `TSG2026`. Implementing `ITagCollector` in generated code would pull + `Microsoft.Extensions.Compliance.Classification` into every consumer's build, so only the pair-returning shape is + supported. This is recorded in [Platform Support](Platform-Support.md). +- A `[TagProvider]` parameter makes the method state-based (v2), even if `V1` was requested, because + `LoggerMessage.Define` has no tag bag to add the pairs to. + ## Next pages - [Generation v1](Logging-Generation-v1.md) — `LoggerMessage.Define` mode and its limits diff --git a/docs/wiki/Metrics.md b/docs/wiki/Metrics.md index 32e12754..11ab8585 100644 --- a/docs/wiki/Metrics.md +++ b/docs/wiki/Metrics.md @@ -5,7 +5,14 @@ All metric-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for meter generation, decorate it with the `[Meter]` attribute. -When creating the meter types, the [`IMeterFactory`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.imeterfactory) is used when available (it is not available on .NET Framework 4.8, where a `new Meter(...)` is created instead). +When creating the meter types, the [`IMeterFactory`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.imeterfactory) is used **when the compilation can resolve it**. That is a symbol probe, not a target-framework check: `IMeterFactory` ships in Microsoft.Extensions.Diagnostics.Abstractions, so a .NET Framework 4.8 project that references it gets the injected-factory constructor too, while a modern project pinned to an older line may not and falls back to `new Meter(...)`. + +When the generated constructor takes an `IMeterFactory`, the container has to be able to supply one — register it with `services.AddMetrics()`: + +```csharp +services.AddMetrics(); // registers IMeterFactory +services.AddInventoryMetrics(); // the generated registration +``` ## Meter naming diff --git a/docs/wiki/Platform-Support.md b/docs/wiki/Platform-Support.md index d68c2afa..670d4e9a 100644 --- a/docs/wiki/Platform-Support.md +++ b/docs/wiki/Platform-Support.md @@ -17,8 +17,13 @@ release; see [Release Flow](Release-Flow.md#release-gates). case value, never as the union struct. When the union declares the non-boxing access pattern (`bool HasValue` plus a `TryGetValue(out T)` overload per case type) the generated accessor calls those, so the union struct is never boxed to read it; a union declared with the `union` keyword falls back to - its `Value` property, which the language form already holds boxed. See - [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md#union-types). + its `Value` property, which the language form already holds boxed. +- **Purview.Unions `[GenerateUnion]` types as the same values.** Those members are emitted into *generated* output, so + this generator cannot see them; it reads the case list from the surfaces that are in source — the attribute's + `typeof` list, the union's partial static factory signatures, a nested `IUnionMembers` provider, then its + constructors — and emits the non-boxing accessor against the members the unions generator will produce. No reference + to Purview.Unions is taken: both attributes are matched by metadata name and namespace. See + [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md#purviewunions-generateunion-types). - **`ActivitySourceFactory` (opt-in).** `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` makes the generated classes create their sources from the injected .NET 11 factory, registered with `services.AddTracing()`, instead of a static `ActivitySource`. See @@ -45,7 +50,7 @@ test legs and benchmarks no longer build them. The generated code still works on | Consumer target | Activities | Logging v1 | Logging v2 | Metrics | Meters from `IMeterFactory` | `UseActivitySourceFactory` | Verified by | | --- | --- | --- | --- | --- | --- | --- | --- | -| .NET Framework 4.8 (`net48`+) | Yes | Yes | Yes¹ | Yes | No (`new Meter(...)`) | With System.Diagnostics.DiagnosticSource 11+ | Integration tests (`net48` leg, Windows), `SampleApp.Net48` | +| .NET Framework 4.8 (`net48`+) | Yes | Yes | Yes¹ | Yes | When the compilation resolves `IMeterFactory` (reference Microsoft.Extensions.Diagnostics.Abstractions) | With System.Diagnostics.DiagnosticSource 11+ | Integration tests (`net48` leg, Windows), `SampleApp.Net48` | | .NET 10 | Yes | Yes | Yes¹ | Yes | Yes | With System.Diagnostics.DiagnosticSource 11+ | Integration tests (`net10.0` leg), `SampleApp`, `SampleApp.MultiProject` | | .NET 11 | Yes | Yes | Yes¹ | Yes | Yes | Yes | Integration tests (`net11.0` leg), packed-consumer gate | | `.NET 8`, `.NET 9` | Not supported: reported as [`TSG1011`](Diagnostics.md) (unsupported target framework). Target `net10.0`+ or `net48`+. | | | | | | - | @@ -99,22 +104,70 @@ source and a static one. | Feature | Generator support | Minimum | Source | | --- | --- | --- | --- | | C# 15 union types (`union` declarations and hand-written `[Union]` types) as tag, baggage, log and metric-tag values | Implemented: the case value is recorded, read through the non-boxing `TryGetValue`/`HasValue` pattern when the union declares one | `net48`+, any TFM the type resolves on | [Union types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union) | +| Purview.Unions `[GenerateUnion]` types as tag, baggage, log and metric-tag values | Implemented: the case list is read from the source-visible surface (attribute `typeof` list, partial static factories, nested `IUnionMembers` provider, constructors) and the accessor uses the non-boxing pattern the unions generator emits. No package reference is taken; both attributes are matched by metadata name. A target with no visible case list is recorded as an ordinary value and reported by Purview.Unions as `UNI0003`. | `net48`+, any TFM the type resolves on; requires the Purview.Unions generator | [Purview.Unions](https://github.com/purview-dev/unions) | | `ActivitySourceOptions`, `services.AddTracing()` | Implemented: `UseActivitySourceFactory` with `Version`/`TelemetrySchemaUrl`, diagnostics `TSG3023`/`TSG3024` | `net11.0`, or System.Diagnostics.DiagnosticSource 11 + Microsoft.Extensions.Diagnostics 11 | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#activity-tracing-configuration) | | `Gauge` (.NET 9) and `InstrumentAdvice` histogram buckets (.NET 9) | Implemented: `[Gauge]`, `[Histogram(HistogramBucketBoundaries = ...)]`, diagnostics `TSG4010`-`TSG4012` | `net48`+ with System.Diagnostics.DiagnosticSource 9 | [Instruments](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/metrics-instrumentation) | | `ActivitySourceFactory`, `services.AddTracing()` | Implemented: `UseActivitySourceFactory`, diagnostic `TSG3023` | `net11.0`, or System.Diagnostics.DiagnosticSource 11 + Microsoft.Extensions.Diagnostics 11 | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#activity-tracing-configuration) | | C# 15 non-virtual static interface members on older runtimes | Implemented: static members are skipped | C# 15 (.NET 11 SDK) | [What's new in C# 15](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15) | | Logging and diagnostics abstractions in the shared framework | Documented: no reference is needed on `net11.0` | `net11.0` | [Breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/11/extensions-in-shared-framework) | +| `[LoggerMessage]` parameter-shape parity (`SYSLIB1028`/`SYSLIB1029`) | Implemented: `TSG2022` rejects `params`, `TSG2023` rejects `ref`/`out`/`in`/`scoped` and ref struct parameters. Both are carried as invalid and generate a throw stub rather than code that fails to compile. | any TFM | [`[LoggerMessage]` diagnostics](https://github.com/dotnet/runtime/blob/release/11.0/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/DiagnosticDescriptors.cs) | +| `[LoggerMessage(SkipEnabledCheck = true)]` | Implemented: `[Log(SkipEnabledCheck = true)]` and the same property on every level attribute omit the generated `IsEnabled` guard | any TFM | [`LoggerMessageAttribute.SkipEnabledCheck`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessageattribute.skipenabledcheck) | +| Generic logging methods (the .NET 11 `[LoggerMessage]` generator supports them) | Implemented: a generic method is generated with the interface method's type parameters and constraints mirrored, and always uses the state-based (v2) path because `LoggerMessage.Define` cannot carry an open generic. `TSG1005` now reports only the shapes that cannot be generated. | any TFM | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#generic-source-generated-logging-methods) | +| Generic interfaces | Implemented: the generated class becomes generic over the interface's type parameters with the same constraints, and the DI extension registers the open generic (`typeof(IFoo<>)`). `TSG1004` now reports only a nested generic interface or the `allows ref struct` anti-constraint. | any TFM | [Generic type parameters](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generic-type-parameters) | +| `[LogProperties(Transitive = true)]` nested property expansion | Implemented: nested properties are flattened to dotted tag names, with `[LogPropertyIgnore]` and `SkipNullProperties` honoured at every level and a 32-type/deepest-chain bound (`Microsoft.Gen.Logging`'s `MaxTypeTraversalDepth`). See [Logging](Logging.md#logpropertiestransitive--true). | any TFM | [`LogPropertiesAttribute.Transitive`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.logpropertiesattribute.transitive) | +| `[TagProvider]` contributed log tags | Implemented for the pair-returning provider shape (`IEnumerable>` and the `string` variant), in both the state-based and `LoggerMessage.Define` paths (the latter upgrades the method to the state-based path). Diagnostics `TSG2024`-`TSG2027`. | any TFM | [`TagProviderAttribute`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.tagproviderattribute) | +| `Activity.AddException` | Implemented: when the compilation resolves it, the generated exception helper calls `activity.AddException(exception, tags, timestamp)` and passes `exception.escaped` through the `TagList`; the framework writes `exception.type`/`message`/`stacktrace` itself. Otherwise the hand-built `ActivityEvent` is kept, so `net48` and older DiagnosticSource keep working. | `net48`+ with System.Diagnostics.DiagnosticSource 9+; gated on the symbol, never the TFM | [`Activity.AddException`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity.addexception) | +| `MeterOptions.Version` and `MeterOptions.Tags` | Implemented: `[Meter(Version = "1.2.3", Tags = "env=prod,region=eu")]` or the assembly-level `[MeterGeneration(...)]`, comma-separated `key=value`, interface-level overriding assembly-level per key. Without an `IMeterFactory` the version goes to the `Meter(name, version)` constructor, which is the only metadata that overload carries. `MeterOptions.Scope` stays unreachable: it takes an object instance an attribute cannot express. | Any TFM that resolves `IMeterFactory` (Microsoft.Extensions.Diagnostics.Abstractions), including `net48`; without it the version goes to the `Meter(name, version)` constructor and the tags do not apply | [`MeterOptions`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.meteroptions) | | Blazor WebAssembly diagnostics on by default | Documented above | .NET 11 SDK | `Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets` in the .NET 11 SDK | +## .NET 11 in this repository's own build + +.NET 11 is GA (SDK 11.0.100, 10 November 2026). The generator targets `netstandard2.0`, so a runtime feature cannot apply +to the generator itself: the entries below are decisions about the projects that *build, test and validate* this package, +and about what the generator emits for a `net11.0` consumer. + +`Purview.BuildSdk` 2.0.0 owns the .NET 11 opt-ins, so each is one SDK setting rather than a property copied into projects +(`Sdk/Targets/Modern.targets`, applied only when the target framework's major version is 11 or later). + +| Dimension | .NET 11 feature | State here | Why | +| --- | --- | --- | --- | +| async, runtime, debug | Runtime Async V2 (`runtime-async=on`, no `EnablePreviewFeatures` needed on `net11.0`) | On for every `net11.0` project (`PurviewRuntimeAsync=true`, the SDK default) | The runtime libraries themselves ship built with it, so a `net11.0` app built without it still runs against libraries that opted in. Suspension and resumption move out of compiler-generated state machines, which lowers overhead and leaves the real async method on the live call stack, so a profiler, debugger or `new StackTrace()` names the code rather than its infrastructure. `netstandard2.0` (the generator) and `net10.0` are untouched. | +| stability, debug | C# 15 updated memory-safety rules (`updated-memory-safety-rules`) | On for `net11.0` (`MemorySafetyRules=2`, the SDK default); `AllowUnsafeBlocks` deliberately stays off | `unsafe` becomes a caller obligation rather than a property of a member, so an unmanaged-memory access is visible where it happens. Independent of `LangVersion`, so `latest` is enough. The generator emits no pointer code, so nothing here needs it; it is on because the strictest useful combination costs nothing. The preview *syntax* tier (`MemorySafetyRules=1`) is not used: it needs `LangVersion=preview` and this repository has no unsafe code to relax. | +| build | `AnalysisLevel=latest` now resolves to the .NET 11 rule set (the .NET 11 SDK fixed `latest` having meant the .NET 9 rules) | On (SDK default) | New and corrected CA rules apply without a version to bump: CA1873 is quieter and names the reason an argument was flagged, and the CA1515/CA1034/CA1859 false positives with extension members and default interface implementations are gone. | +| build | MSBuild server warm between CLI invocations; Native AOT `dotnet` CLI host | On (SDK default, nothing to configure) | Repeated build and test invocations stop paying the CLI startup path. `Directory.Build.rsp` already pins `-mt` for the parallel build path. | +| testing | Microsoft.Testing.Platform run-level switches (`--timeout`, `--maximum-failed-tests`, `--results-directory-layout per-module`) | On in `just test` and `just test-floor` | A hung test application cannot stall the run, a broken change stops at the first N failures instead of burning the whole suite, and the four legs (`net48`, `net10.0`, `net11.0`, `Repository.UnitTests`) get their own report directories instead of overwriting one another's files. These are MTP switches, not VSTest ones. | +| packaging | NuGet audit over the whole graph (`NuGetAuditMode=all`) | On in `Directory.Packages.props` | The SDK defaults to auditing only directly declared packages. This package ships a compiler-host assembly, so a vulnerable transitive dependency is one it hands to every consumer's build; with `all`, it is reported on every restore instead of being invisible in a graph nobody reads. | +| packaging, stability | Lowest compatible dependency floor (`DependencyFloor`) | Proved by `just test-floor` and the `dependency-floor` PR job | Every declared version is a lower bound, so the floor this package declares becomes the floor every consumer inherits. `Directory.Packages.props` keeps one property per package family (runtime-tracked `10.0.`, dotnet/extensions `10..0`) plus the switch; the ordinary restore takes the latest servicing patch, `-p:DependencyFloor=true` the oldest version the code and tests compile and pass against. | + +Everything else in .NET 11 was evaluated and deliberately left alone: + +| Evaluated | Not used, because | +| --- | --- | +| Memory-safety preview *syntax* (`MemorySafetyRules=1`) | Needs `LangVersion=preview` and only relaxes pointer contexts. The generator has no unsafe code, and a preview language version in a shipped compiler component is a risk with no payoff. | +| `EnablePreviewFeatures` / preview APIs | No `[RequiresPreviewFeatures]` API is used. Runtime Async is the one .NET 11 feature this repository wants, and on `net11.0` it needs no opt-in flag. | +| JIT and hardware improvements (bounds-check elimination, constant-folded `SequenceEqual`, `Task`/`ValueTask` factory intrinsics, Native AOT interface dispatch, FP16, x86-64-v2 baseline) | Nothing to adopt and nothing to opt into: they are the runtime a `net11.0` consumer gets. The updated minimum hardware is a deployment decision for that consumer, not a generator setting. | +| `System.Text.Json` union serialization and closed-hierarchy polymorphism, `Process.RunAndCaptureTextAsync`, Zstandard, JSON Lines, `Base64` and Rune APIs | Application-facing library APIs. The generator emits `ActivitySource`, `ILogger` and `Meter` calls and serialises nothing. | +| `EnablePackageValidation` / API compatibility baseline | The package ships analyzers only, with no `lib/` surface to compare against a previous release. `PackValidation` in `.config/purview-build.json` is the gate that fits this package's shape. | +| File-based apps (`#:include`, `dotnet run -e`), solution filters (`.slnf`), traversals, affected tests, mobile device selection | Developer-workflow features for applications and for repositories shaped differently from this one. `dotnet watch` Aspire integration is irrelevant: the sample's AppHost is validated by `just build-s` and `just test-s`. | + +## Considered and rejected + +| Change | Decision and reason | +| --- | --- | +| Replacing the generator's own tagged models (`closed record` `InstrumentAttributeRecord` and `ActivityMethodTarget`) with a value-based `[Union]` struct | **Kept as `closed` records.** The models allocate one object per method per generation pass and dispatch through abstract properties, which a value union would remove — but the measured per-method cost of that mechanism is a few tens of nanoseconds, against a pass that also resolves every symbol, builds the message templates and writes the source text, so the ceiling for any win is a fraction of one percent. The win is not even uniform: on .NET Framework a `readonly struct` union with a tag switch is measurably *slower* than the record hierarchy for the same build-and-dispatch loop, so the change would trade a simpler, exhaustiveness-checked model for a worse .NET Framework build. The exhaustive `switch` over a `closed` hierarchy is also enforced by the compiler, which a tag-plus-payload struct does not give. `benchmarks/Purview.Telemetry.Benchmarks` carries `GeneratorModelBenchmarks`, which compares the two shapes on every runtime leg. The tables in [Performance](Performance.md) predate it, so re-run the suite (a Release build, as [Performance](Performance.md#reproducing) describes) before quoting a comparison. | + ## Not yet supported | Feature | Why it is not supported yet | Source | | --- | --- | --- | -| Generic logging methods (the .NET 11 `[LoggerMessage]` generator supports them) | Generic interface methods still report `TSG1005`. Supporting them needs generic implementations across all three targets, not only logging. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#generic-source-generated-logging-methods) | -| Parity with the new `[LoggerMessage]` diagnostics for `params`/`scoped` and `ref struct` parameters (SYSLIB1028, SYSLIB1029) | No equivalent `TSG` rule exists yet. Such parameters are not rejected up front. | [`DiagnosticDescriptors.cs` (release/11.0)](https://github.com/dotnet/runtime/blob/release/11.0/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/DiagnosticDescriptors.cs) | +| `[TagProvider]`'s `ITagCollector` provider shape (`static void M(ITagCollector collector, T value)`) | Reported as `TSG2026`. Implementing `Microsoft.Extensions.Logging.ITagCollector` in generated code would pull `Microsoft.Extensions.Compliance.Classification` into every consumer's build for one optional feature, so only the pair-returning shape (`IEnumerable>` / `IEnumerable>`) is accepted. | [`ITagCollector`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.itagcollector) | +| An end-to-end test with Purview.Unions and this generator in one compilation | The `[GenerateUnion]` support is proven by integration tests that declare a stand-in `Purview.Unions.GenerateUnionAttribute` in the test source, because Purview.Unions is not on a CI-safe package feed yet and the shipped generator must not take a reference to it. Running both generators together is a follow-up once the package is published. The generator reads the case list from source, so nothing in the product depends on the other generator's output. | [Purview.Unions](https://github.com/purview-dev/unions) | +| A `Nullable` (nullable struct) property expanded transitively by `[LogProperties(Transitive = true)]` | Logged as a single tag. Reaching its members needs a `.Value` hop that the generated null-conditional chain cannot express, so the branch stops there rather than emitting code that does not compile. | [`LogPropertiesAttribute.Transitive`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.logpropertiesattribute.transitive) | +| A metric method whose measurement is one of the method's own type parameters | Reported as `TSG1005`, and the method is emitted as a throwing stub. An instrument is a **field**, and a field cannot name a method's type parameter. Declaring that type parameter on the interface makes the generated class generic and the instrument field can then use it; that shape is supported. | [Metrics](Metrics.md) | +| A nested generic interface | Reported as `TSG1004`. The enclosing type's type arguments cannot be repeated on the generated class. | [Generic types](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/) | | `ActivitySourceOptions` version, tags and `TelemetrySchemaUrl` for factory-created sources | Version and `TelemetrySchemaUrl` are implemented; `Tags` are not, because an attribute cannot express the `IEnumerable>` the option takes. Set them in `AddTracing` configuration instead. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | | `AddTracing` rules from generated registrations | Rules are application configuration, so the generated `Add{Interface}()` does not call `AddTracing()`. Call it yourself. | [Distributed tracing configuration](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-configuration) | | `ActivityTraceFlags.RandomTraceId` / `Activity.HasRandomizedTraceId` | Nothing to generate: the runtime sets the flag on the activities it creates, and generated code never rewrites trace flags. | [dotnet/runtime#124509](https://github.com/dotnet/runtime/issues/124509) | | New `System.Diagnostics.Metrics` APIs | `Gauge` and `InstrumentAdvice` are generated (see above). `ObservableGauge` receiving the underlying value instead of `Measurement` is not. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | | `Activity.AddException` (.NET 9) | Exception events are built with the OpenTelemetry `exception.*` tags directly, which works on every target, `net48` included. | [`Activity.AddException`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity.addexception) | -| A `Microsoft.Extensions.Telemetry.Abstractions` 11 line | None is published yet. Logging v2 uses the 10.x package on every target. | [dotnet/extensions releases](https://github.com/dotnet/extensions/releases) | +| A `Microsoft.Extensions.Telemetry.Abstractions` 11 line on `net11.0` | The `11.0.0` line is published, but logging v2 stays on `10.11.0` on every target: this package declares the lowest framework line it supports (`net10.0`), and a package reference raises every consumer's floor to it. A `net11.0` consumer that wants the 11 line references it there itself. | [dotnet/extensions releases](https://github.com/dotnet/extensions/releases) | diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 642c3a02..90f8c017 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -51,6 +51,7 @@ check fails rather than passing. | Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes. | `just build`, `just lint`, `just test` | `pr.yml` `build` | | Pack and pack validation | The package packs; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` (which also runs the rule proof) | `pr.yml` `build` | | Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of the package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` (and the end of `just pipeline-pack-validate`) | `pr.yml` `pack-validation`, in parallel with the consumer gate | +| Dependency floor | Every declared dependency version is a lower bound, so the floor this package declares becomes the floor every consumer inherits. `-p:DependencyFloor=true` drops each package family to the oldest version the generator's code and tests compile and pass against, while the ordinary leg resolves the latest servicing patch of the same line. The two together prove the bottom and the top. | `just test-floor` | `pr.yml` `dependency-floor` | | Packed consumers | The package, packed to an isolated feed and cache, works for consumers that reference it as a package - each with a class library of its own generated telemetry. (a) A **Native AOT** console publishes with `-warnaserror` and no IL2xxx/IL3xxx warning, then runs with an in-process `ActivityListener`, `MeterListener` and capturing logger provider and checks the multi-target method (Activity, Information log and counter from one call), tags, baggage, events and the OpenTelemetry exception event, every metric measurement, v1 and v2 logging, log-level gating and the `TelemetryNames` aggregation. (b) A **Blazor WebAssembly** app, using the telemetry from a page and a service, publishes trimmed with `-warnaserror` and with `RunAOTCompilation=true`: the trimmed assemblies keep every generated member, and the WebAssembly AOT compiler compiles every generated method. (c) A **Blazor Server** host renders its page on an in-memory `TestServer`, and the telemetry the page emitted is checked as in (a). (d) Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | | Compiler-host leak scan | No generator, refactorings, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest (`blazor.boot.json`, `dotnet.boot.js`, `dotnet.js`). Purview.SourceGeneratorFramework 1.2.1 put its DLL in every sample's `bin` while every build passed. | `just scan-compiler-host-leaks ...` | the `samples` job (every sample's `bin`) and every consumer in `packed-consumers`; `ReleaseGateTests` fails a job that builds the samples or publishes without it | diff --git a/docs/wiki/Tags-and-Baggage.md b/docs/wiki/Tags-and-Baggage.md index 26f26504..50b8ec9e 100644 --- a/docs/wiki/Tags-and-Baggage.md +++ b/docs/wiki/Tags-and-Baggage.md @@ -121,10 +121,9 @@ Marks a `string` parameter as the status description for an Event that sets an e ## Union types -A parameter whose type is a C# 15 union — declared with the `union` keyword on .NET 11, or a hand-written -type carrying `[System.Runtime.CompilerServices.Union]` — is recorded as its **case value**, never as the -union struct. The generator emits one private, aggressively inlined accessor per union type and calls it -wherever the parameter's value is recorded, so a union behaves like the value it holds: +A parameter whose type is a union is recorded as its **case value**, never as the union struct. The generator emits +one private, aggressively inlined accessor per union type and calls it wherever the parameter's value is recorded, so a +union behaves like the value it holds: ```csharp public record class Ordered(string OrderId); @@ -149,6 +148,12 @@ static object? UnwrapUnion_OrderStatus(OrderStatus value) => value.Value; activity.SetTag("status", UnwrapUnion_OrderStatus(status)); ``` +Three kinds of union are recognised, each by metadata name and namespace — the generator references no union package: + +- a **C# 15 `union` declaration**; +- a **hand-written type carrying `[System.Runtime.CompilerServices.Union]`**; +- a **[Purview.Unions `[GenerateUnion]`](#purviewunions-generateunion-types)** target. + ### Non-boxing access pattern A union declared with the `union` keyword always stores `object?`, so reading it boxes nothing new — the @@ -184,6 +189,65 @@ A union with no value (a default struct, or a `TryGetValue` that matches nothing `[Tag]`/`[Baggage]` union parameter behaves like a `null` value and `SkipOnNullOrEmpty` applies to it. Baggage converts the case value with `?.ToString()`, as it does for any non-`string` parameter. +### Purview.Unions `[GenerateUnion]` types + +A union generated by [Purview.Unions](https://github.com/purview-dev/unions) declares nothing but +`[Purview.Unions.GenerateUnion]` in its own source: the `[Union]` attribute, the constructors, `HasValue` and one +`TryGetValue(out T)` per case are all emitted into *generated* output. Roslyn never shows one generator another +generator's output, so the telemetry generator cannot see any of those members — and it still unwraps the parameter to +its case value, because it reads the case list from the surfaces that **are** in source. + +The case list is taken from the first non-empty of, in the union generator's own order: + +| Order | Source | +| --- | --- | +| 1 | the `[GenerateUnion(typeof(...))]` `typeof` list | +| 2 | the union's partial static factory signatures — `static partial OrderStatus Make(Ordered value);` | +| 3 | a nested `IUnionMembers` provider's static single-parameter `Create` methods | +| 4 | the union's source-visible public single-parameter constructors | + +Cases are normalised exactly as that generator exposes them: a nullable value type becomes its underlying type +(`int?` → `int`), a nullable reference annotation is stripped, and constructed generic types and arrays are kept. A +duplicate case is emitted once. + +```csharp +[Purview.Unions.GenerateUnion(typeof(Ordered), typeof(Rejected))] +public partial struct OrderStatus { } +``` + +```csharp +static object? UnwrapUnion_Testing_OrderStatus(OrderStatus value) +{ + if (value.HasValue) + { + if (value.TryGetValue(out global::Ordered caseValue0)) return caseValue0; + if (value.TryGetValue(out global::Rejected caseValue1)) return caseValue1; + } + + return null; +} +``` + +The accessor always uses the non-boxing pattern for a `[GenerateUnion]` target, because that generator emits +`HasValue` and one `TryGetValue(out T)` per case for every surface. The members are invisible to this generator but +present in the final compilation, so the emitted accessor compiles. For a hand-written `[Union]` type the pattern is +still *detected*, because some declare it and some do not; a `union` declaration has no `TryGetValue` and uses `Value`. + +If no case list is visible in source — `[GenerateUnion]` with no `typeof` list, no factory signatures, no provider and +no constructors — the parameter is **not** treated as a union: no accessor is emitted and the value is recorded as +ordinary. Purview.Unions reports `UNI0003` for that union itself, so the error is reported once, by the generator that +owns it. + +> [!NOTE] +> This reads the *source-visible* case list, so a telemetry build never depends on another generator having run. When +> another generator also needs the case list, surface 3 — a nested `IUnionMembers` provider in your own source — remains +> the portable route, because both generators can read it. + +> [!NOTE] +> An end-to-end test running Purview.Unions and this generator in one compilation is a follow-up: Purview.Unions is not +> yet on a CI-safe package feed, so the generator cannot take a reference to it. The tests in this repository use a +> stand-in `Purview.Unions.GenerateUnionAttribute` declared in the test source. + ### Where unions are supported | Target | Behaviour | diff --git a/global.json b/global.json index 1d02a92a..b21824c0 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ }, "msbuild-sdks": { "Purview.BuildSdk": "2.0.0", - "Aspire.AppHost.Sdk": "13.6.4" + "Aspire.AppHost.Sdk": "13.6.5" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index b31230db..578ac05b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "5.3.0", + "version": "6.0.0", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", diff --git a/samples/SampleApp.Net48/README.md b/samples/SampleApp.Net48/README.md index 978b3a15..e4802f0a 100644 --- a/samples/SampleApp.Net48/README.md +++ b/samples/SampleApp.Net48/README.md @@ -12,6 +12,10 @@ via a simple console application. `#nullable enable` directive. No extra configuration is needed. - **Full telemetry stack on net48** — ActivitySource, ILogger, and System.Diagnostics.Metrics all work via NuGet packages (`System.Diagnostics.DiagnosticSource`, `Microsoft.Extensions.*`). +- **Injected `IMeterFactory` on .NET Framework** — the generator probes the *compilation* for + `System.Diagnostics.Metrics.IMeterFactory`, not the target framework. Referencing + `Microsoft.Extensions.Diagnostics` (which ships `net462` assets) gives `net48` the same + factory-taking constructor as a modern target, so the sample registers one with `services.AddMetrics()`. ## Running the sample @@ -78,4 +82,5 @@ SampleApp.Net48/ |---|---| | `System.Diagnostics.DiagnosticSource` | ActivitySource, ActivityListener, Meter, MeterListener | | `Microsoft.Extensions.DependencyInjection` | Service registration (`AddWeatherServiceTelemetry()`) | +| `Microsoft.Extensions.Diagnostics` | `services.AddMetrics()`, which registers the `IMeterFactory` the generated meter takes | | `Microsoft.Extensions.Logging.Console` | Console log output | diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs index f4ea4272..3707cf0a 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs @@ -43,6 +43,11 @@ static void Main() services.AddLogging(logging => logging.AddConsole().SetMinimumLevel(LogLevel.Trace)); + // Registers IMeterFactory. The generator probes the compilation for that type rather than the target + // framework, so a net48 project that can see it gets the injected-factory constructor - and DI then has + // to be able to supply one. Microsoft.Extensions.Diagnostics ships net462/netstandard2.0 assets for it. + services.AddMetrics(); + services.AddScoped(); services.AddWeatherServiceTelemetry(); diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs index 561b46cc..f86efdfb 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs @@ -1,7 +1,10 @@ using Purview.Telemetry; -// Sets the default ActivitySource name used by TelemetryNames.ActivitySourceNames. +// Sets the default ActivitySource name used by TelemetryNames.ActivitySourceNames. The meter name is not set +// here, so it falls back to the assembly name. [assembly: ActivitySourceGeneration("sample-weather-app-net48")] // Generates the TelemetryNames class. It is opt-in: the generated names are always recorded in // assembly metadata, and only the project that registers them needs the class. [assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +// Names the meter, so the reported meter name does not depend on the assembly name. +[assembly: MeterGeneration("sample-weather-app-net48")] diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/SampleApp.Net48.ConsoleApp.csproj b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/SampleApp.Net48.ConsoleApp.csproj index 94cde4d3..6e9e1991 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/SampleApp.Net48.ConsoleApp.csproj +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/SampleApp.Net48.ConsoleApp.csproj @@ -18,6 +18,7 @@ + diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs index c94f41ae..c0c0ac74 100644 --- a/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs +++ b/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs @@ -113,26 +113,23 @@ static IHostApplicationBuilder AddDefaultOpenAPI( string[] versions = ["v1"]; foreach (var description in versions) { - builder.Services.AddOpenApi( - description, - options => - { - options.ApplyAPIVersionInfo( - openApiSection.GetRequiredValue("Document:Title"), - openApiSection.GetRequiredValue("Document:Description") - ); - - // Clear out the default servers so we can fallback to - // whatever ports have been allocated for the service by Aspire - options.AddDocumentTransformer( - (document, _, _) => - { - document.Servers = []; - return Task.CompletedTask; - } - ); - } - ); + builder.Services.AddOpenApi(description, options => + { + options.ApplyAPIVersionInfo( + openApiSection.GetRequiredValue("Document:Title"), + openApiSection.GetRequiredValue("Document:Description") + ); + + // Clear out the default servers so we can fallback to + // whatever ports have been allocated for the service by Aspire + options.AddDocumentTransformer( + (document, _, _) => + { + document.Servers = []; + return Task.CompletedTask; + } + ); + }); } return builder; diff --git a/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs b/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs index d4a9f020..bcf20e0e 100644 --- a/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs +++ b/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs @@ -30,8 +30,9 @@ public async Task GetWeatherForecastsAsync( telemetry.RequestSuccess(); - var forecasts = - await result.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) ?? []; + var forecasts = await result.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ) ?? []; if (forecasts.Length == 0) telemetry.NoForecastsRecieved(activity); diff --git a/skills-lock.json b/skills-lock.json index fc537d0f..0194adf0 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,131 +1,149 @@ { - "version": 1, + "version": 2, "skills": { "purview-architecture": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + "computedHash": "2500e935ff2ccb350f26ebb9be321dc9f99800121aa9412fdc597f95c2c465c8" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + "computedHash": "23af6d9baeb408e1e3f4034cef6539d7efa3fecb1b4da918cb539bd8dfbd5a00" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "5ddad1fe7d1dc65c1b352743e5cd616d4720a33228b06faa6259572cd4a8c265" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + "computedHash": "9b9593e093aaf07ec0a427e7874c1dd2cda35b9a13a6701d7ae10af5ebd985a9" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + "computedHash": "9a3cf78ad4df9c335780bbbfbc9f66f7986b6293359aab19edaadb5c79f70be8" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + "computedHash": "d8b1fb6b95ae5e91f81734616f70a9f3d2d7547dc7406e4eda0f8d674ebf808b" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "computedHash": "babfe657a1eef5be67dc1e4ec387e9e8215fb0263956c4c926465fa27b648604" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "8e1dc7c0f499eb742264d5cc293784758c5f0499f849671a0be3353f91f89491" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" + }, + "purview-buildsdk-skills-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skills-justfile/SKILL.md", + "computedHash": "a41f7bbd28333e7f2e40cadbbdd35a75b2f626d72e53901cbc79c7325ac2e50c" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + "computedHash": "e7e00ede43f5ee8aa4517329e4fdbde00a5a01bd8e98ea42990c851060bcaf9e" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "070634caa90d032e2319d6799edbee973be0f9380008917474909328e74f143b" }, "purview-engineering-principles": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-engineering-principles/SKILL.md", - "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + "computedHash": "d1b0d2b50e635160f96d03d2790cb725242434bb28b1b47913371bb39d83215b" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "040b34d9cd41f23e71dbe1500b0748793377703d1032e1135ed0a3eeeee4a7f8" }, "purview-persistence": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-persistence/SKILL.md", - "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + "computedHash": "28e92fa8bccc241ca864b6731148bd721f47699baf3a1a2ec07cce42ed650bae" }, "purview-sourcegenerator-attribute-data-models": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-attribute-data-models/SKILL.md", - "computedHash": "84c309a0094a44a8ca76f0078c1fccee1f3c99b96b13a90b5b01260274c0cda6" + "computedHash": "475bf4ef2c6e5c29e02725383f97fb0aea60243c05a2d779b03533c392c6d395" }, "purview-sourcegenerator-codewriter": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", - "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + "computedHash": "5874c7773f395a5fea123f447bc500a687760f256eb5f7646022f9fbd22cd111" }, "purview-sourcegenerator-diagnostics": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", - "computedHash": "c3d035e053ebcde99de95399c46e9d822d01c9837154ecff5792f5f0dfafa0ca" + "computedHash": "8e3571221c2a2225f3826b0c501cd28ea2a27e91defd121f45117c0e36a308fd" }, "purview-sourcegenerator-packaging": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", - "computedHash": "ee2440f1e0aa0cea731eb3eae2ea3c4e9ea77502867abc21e9f0a3c61f20c640" + "computedHash": "6044272af5982bbf9750a198ca8094c17106579de36dc259712cedc166b7b2fb" }, "purview-sourcegenerator-pipeline": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", - "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + "computedHash": "62835d55eab1ccf233241796d34d17bfd7b2fbe4be03424bf9a3a40d8ad2e6a7" }, "purview-sourcegenerator-testing": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", - "computedHash": "8ea0b5a51f045721c730a3bc57e3a14e79195b9b99738430fbb056263785b75c" + "computedHash": "ef37df157bd9a589d487e79cffdecc4160aee59ca9000aea55d7204af097e883" }, "purview-sourcegenerator-tunit-tests": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", - "computedHash": "5b801f9bf792f2ae0baeb2a3835680b9f95dbaed2e57ced43815449e64e19132" + "computedHash": "29f966d9331a556bc3a7d5aa8ad8fe7b1a9091557d629e9c23f65ebd14e3aee7" }, "purview-telemetry": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-telemetry/SKILL.md", - "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + "computedHash": "f92aab10e3d2be7b24be559316537ba7d0485d0e479c67f4d5c6126f5f2fc652" }, "purview-testing-conventions": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + "computedHash": "e053513db856852a23aad0e22625e1408fdd63377acb793e6d720fe37b9f7214" } } } diff --git a/skills/purview-telemetry-design/SKILL.md b/skills/purview-telemetry-design/SKILL.md index 0b93e5f2..a0e7d862 100644 --- a/skills/purview-telemetry-design/SKILL.md +++ b/skills/purview-telemetry-design/SKILL.md @@ -91,6 +91,42 @@ Rules: | `[ExcludeTargets(Targets.Activities)]` | Excludes the parameter from Activity generation only. | | `[ExpandEnumerable(maximumValueCount: N)]` | Expands an array/IEnumerable into indexed log properties. | +## Union-typed parameters + +A parameter whose type is a union is recorded as its **case value**, never as the union struct, which has no meaningful +`ToString`. The generator emits one private, aggressively inlined accessor per union type and reads each case through +the union's own `TryGetValue` overloads when it declares them (a `bool HasValue` property plus one `TryGetValue(out T)` +per case), so the union struct is never boxed to read it. A union that holds no value records `null`. Works on every +supported target, `net48` included: detection is by symbol, not by framework. + +```csharp +public union OrderOutcome(OrderPlaced, OrderRejected); + +[Activity] +Activity? PlacingOrder([Tag] OrderOutcome outcome); +``` + +Three kinds of union are recognised: + +| Union | How the case list is found | +| --- | --- | +| C# 15 `union` declaration | the language's own generated constructors; read through `Value` | +| Hand-written `[System.Runtime.CompilerServices.Union]` | its `IUnionMembers` provider, otherwise its constructors; the non-boxing pattern is *detected* | +| Purview.Unions `[GenerateUnion]` | the surfaces that are in **source**, in this order: the attribute's `typeof` list, partial static factory signatures, a nested `IUnionMembers` provider, then the constructors | + +A `[GenerateUnion]` union declares nothing but the attribute in its own source — the `[Union]` attribute, constructors, +`HasValue` and `TryGetValue` are emitted by the unions generator into generated output, which this generator never sees. +The accessor is therefore emitted against members that will exist in the final compilation, and it always uses the +non-boxing pattern, because that generator emits it for every surface. Cases are normalised as that generator exposes +them (`int?` → `int`, nullable annotation stripped). A `[GenerateUnion]` target with no case list visible in source is +recorded as an ordinary value, not as a union: Purview.Unions reports `UNI0003` for it. + +If another generator also needs the case list, a nested `IUnionMembers` provider in your own source is the portable +route — both generators can read it. There is no reference from this package to Purview.Unions. + +Prefer a union over `object` for a tag, baggage or log value that can be one of a fixed set of types: the call site +keeps exhaustive matching, and the generated accessor knows every case instead of discovering it at run time. + ## Testing Inject the interface in your service and mock it in tests. The generated interface has no implementation code in the interface itself, so standard mocking frameworks (NSubstitute, Moq, TUnitMocks) work directly. diff --git a/skills/purview-telemetry-diagnostics/SKILL.md b/skills/purview-telemetry-diagnostics/SKILL.md index 7a9b5ca1..b2f0c704 100644 --- a/skills/purview-telemetry-diagnostics/SKILL.md +++ b/skills/purview-telemetry-diagnostics/SKILL.md @@ -37,8 +37,8 @@ carry the message, so a reworded message is recorded in the CHANGELOG instead. | `TSG1001` | Error | A method on a multi-target interface has no explicit generation attribute — inference is not supported there. | | `TSG1002` | Error | Two attributes from the same target family on one method. Only one Activity, Logging or Metrics attribute per method. | | `TSG1003` | Error | Two methods on the interface share a name. | -| `TSG1004` | Error | Generic interfaces are not supported. | -| `TSG1005` | Error | Generic methods are not supported. | +| `TSG1004` | Error | A generic interface cannot be generated: only a top-level generic interface whose type parameters the generated class can mirror is supported. A nested generic interface, or a type parameter carrying the `allows ref struct` anti-constraint, is not. | +| `TSG1005` | Error | A generic method cannot be generated: a type parameter carrying `allows ref struct`, or a **metric** measurement that is one of the method's own type parameters (the instrument is a field, and a field cannot name a method's type parameter). The method is emitted as a throwing stub. | | `TSG1006` | Warning | `ExcludeTargets` names a target not present on the method. | | `TSG1007` | Warning | `ExcludeTargets` leaves a target with an empty or invalid parameter set. | | `TSG1008` | Warning | An `Activity`-typed parameter on a method with no `[Activity]`/`[Event]`/`[Context]`. | @@ -59,6 +59,12 @@ carry the message, so a reworded message is recorded in the CHANGELOG instead. | `TSG2007` | Warning | A scoped log declares a `LogLevel`; it is ignored. | | `TSG2008` | Warning | `[ExpandEnumerable]` has a `MaximumValueCount` above the recommended default of 5. | | `TSG2021` | Error | A log method returns something other than `void` or `IDisposable`. (`Activity` is allowed when the method is also an Activity method.) | +| `TSG2022` | Error | A logging parameter is declared `params`. Take an array and mark it `[ExpandEnumerable]` instead. Parity with `SYSLIB1028`. | +| `TSG2023` | Error | A logging parameter is `ref`/`out`/`in`/`scoped`, or a ref struct such as `Span`; the generated state struct cannot capture it. Parity with `SYSLIB1029`. | +| `TSG2024` | Error | A `[TagProvider]` names a type that cannot be resolved. The parameter is still logged, without the provider's extra tags. | +| `TSG2025` | Error | A `[TagProvider]` names a method that does not exist on the provider type. The parameter is still logged, without the extra tags. | +| `TSG2026` | Error | A `[TagProvider]` method has an unusable signature: it must be a visible static method taking one parameter the annotated parameter can be passed to, and returning `IEnumerable>` (or the `string` variant, or a type implementing one). The .NET 11 `ITagCollector` collector shape is deliberately not accepted. | +| `TSG2027` | Warning | `[TagProvider]` is on a parameter of a method that produces no log entry, so the provider would never run. | ## Activities — `TSG3xxx` @@ -85,6 +91,7 @@ carry the message, so a reworded message is recorded in the CHANGELOG instead. | `TSG3021` | Info | An `[Event]` records an exception under the OpenTelemetry rules but is not named `"exception"`. | | `TSG3022` | Warning | An Activity method returns non-nullable `Activity`. | | `TSG3023` | Warning | `UseActivitySourceFactory` is set but `System.Diagnostics.ActivitySourceFactory` (.NET 11, or DiagnosticSource 11+) cannot be resolved; a static `ActivitySource` is generated instead. Target `net11.0` or remove the option. | +| `TSG3024` | Warning | `Version` or `TelemetrySchemaUrl` is set on `[assembly: ActivitySourceGeneration(...)]` without `UseActivitySourceFactory`. A static `ActivitySource` has nowhere to put them, so they are ignored; add the factory or remove them. | ## Metrics — `TSG4xxx` @@ -100,6 +107,9 @@ carry the message, so a reworded message is recorded in the CHANGELOG instead. | `TSG4007` | Error | An observable metric returns `bool`. | | `TSG4008` | Error | An `AutoCounter` does not return `void`. | | `TSG4009` | Warning | The instrument name matches the instrument type name. Name what is measured, not the instrument. | +| `TSG4010` | Warning | `[Gauge]` needs `System.Diagnostics.Metrics.Gauge` (.NET 9, or System.Diagnostics.DiagnosticSource 9+). The compilation cannot resolve it, so no instrument is generated and the method throws `NotSupportedException`. | +| `TSG4011` | Warning | `HistogramBucketBoundaries` needs `System.Diagnostics.Metrics.InstrumentAdvice` (.NET 9, or System.Diagnostics.DiagnosticSource 9+). The histogram is generated without the boundaries. | +| `TSG4012` | Warning | `HistogramBucketBoundaries` does not parse as a comma-separated list of ascending, positive numbers (for example `"0.005, 0.01, 0.1, 1"`). No boundaries are generated. | ## The ones people actually hit @@ -158,6 +168,40 @@ measured the cost. `[AutoCounter]` — equivalently `[Counter(AutoIncrement = true)]` — must declare no measurement parameter. The measurement is fixed at 1. +### `TSG1004` — generic interface cannot be generated + +A plain generic interface works: the generated class is generic over the same type parameters, with the same +constraints, and the DI extension registers the open generic (`typeof(IFoo<>)`). What does not work is a **nested** +generic interface (the enclosing type's type arguments cannot be repeated on the class) or a type parameter with the +`allows ref struct` anti-constraint (the class cannot repeat it). Move the interface out of its enclosing type, or +drop the anti-constraint. + +### `TSG1005` — generic method cannot be generated + +Generic methods work, including constraint mirroring (`struct`, `class`, `class?`, `notnull`, `unmanaged`, `new()`, +type-parameter and type constraints) and multiple type parameters. Two shapes are still refused, and the method is +emitted as a throwing stub so the interface stays implemented: + +- a type parameter with the `allows ref struct` anti-constraint, which cannot be repeated on the generated + declaration; +- a **metric** method whose measurement is one of its own type parameters. An instrument is a field, and a field cannot + name a method's type parameter — declare that type parameter on the interface instead, which makes the class generic + and lets the instrument field use it. + +### `TSG2024` / `TSG2025` / `TSG2026` — `[TagProvider]` cannot be used + +`[TagProvider(typeof(T), "M")]` needs a visible static method `M` on `T` that takes one parameter the annotated +parameter can be passed to, and returns `IEnumerable>` (the `string`-valued variant, an +array, or a `List<...>` of either also work). `2024` is the type, `2025` the method, `2026` the signature. In every +case the parameter is still logged — only the contributed tags are missing. The .NET 11 collector shape +(`static void M(ITagCollector, T)`) is reported as `TSG2026`: implementing `ITagCollector` in generated code would drag +`Microsoft.Extensions.Compliance.Classification` into the consumer's build. + +### `TSG2027` — `[TagProvider]` where nothing logs + +The attribute sits on a parameter of a method that produces no log entry — commonly an `[Activity]`-only method on a +multi-target interface. Move it to the logging method. + ### `TSG1011` — unsupported target framework Raised for a compilation that targets neither `net10.0`+ nor `net48`+ (typically `netstandard2.0`, or the dropped `net8.0`/`net9.0`). diff --git a/src/src/SourceGenerator.Refactorings/ConvertActivitySourceToTelemetryRefactoringProvider.cs b/src/src/SourceGenerator.Refactorings/ConvertActivitySourceToTelemetryRefactoringProvider.cs index d9a3ccc2..c334efed 100644 --- a/src/src/SourceGenerator.Refactorings/ConvertActivitySourceToTelemetryRefactoringProvider.cs +++ b/src/src/SourceGenerator.Refactorings/ConvertActivitySourceToTelemetryRefactoringProvider.cs @@ -381,9 +381,9 @@ CancellationToken cancellationToken static List<(ActivitySourceCallInfo Call, string MethodName)> AssignMethodNames(List calls) { - Dictionary signatureToName = new(StringComparer.Ordinal); - HashSet usedNames = new(StringComparer.Ordinal); - List<(ActivitySourceCallInfo, string)> result = new(calls.Count); + Dictionary signatureToName = [with(StringComparer.Ordinal)]; + HashSet usedNames = [with(StringComparer.Ordinal)]; + List<(ActivitySourceCallInfo, string)> result = [with(calls.Count)]; foreach (var call in calls) { @@ -507,7 +507,7 @@ internal static string BuildInterfaceMembers( ) { StringBuilder sb = new(); - HashSet emittedSignatures = new(StringComparer.Ordinal); + HashSet emittedSignatures = [with(StringComparer.Ordinal)]; foreach (var (call, methodName) in callsWithMethods) { @@ -538,9 +538,10 @@ SemanticModel semanticModel ) { // Build invocation map: StartActivity("name") → interface method call - Dictionary invocationMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary invocationMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var (call, methodName) in callsWithMethods) { @@ -549,9 +550,10 @@ SemanticModel semanticModel } // Build field map - Dictionary fieldMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary fieldMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var field in activitySourceFields) { if (field.FieldDeclaration is null) @@ -561,9 +563,10 @@ SemanticModel semanticModel } // Build property map - Dictionary propertyMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary propertyMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var field in activitySourceFields) { if (field.PropertyDeclaration is null) @@ -628,9 +631,10 @@ static Dictionary BuildParamRewriteMap( SemanticModel semanticModel ) { - Dictionary result = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary result = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; // Build a set of qualified type names so we can match by name when // the classDecl has already been rewritten (nodes no longer in the diff --git a/src/src/SourceGenerator.Refactorings/ConvertILoggerToTelemetryRefactoringProvider.cs b/src/src/SourceGenerator.Refactorings/ConvertILoggerToTelemetryRefactoringProvider.cs index 7c211b62..5f557379 100644 --- a/src/src/SourceGenerator.Refactorings/ConvertILoggerToTelemetryRefactoringProvider.cs +++ b/src/src/SourceGenerator.Refactorings/ConvertILoggerToTelemetryRefactoringProvider.cs @@ -386,8 +386,8 @@ CancellationToken cancellationToken var exType = semanticModel.Compilation.GetTypeByMetadataName( TelemetryAttributeNames.System.Exception.MetadataFullName ); - var exTypeStr = - exType?.ToDisplayString(ParamTypeFormat) ?? TelemetryAttributeNames.System.Exception.MetadataFullName; + var exTypeStr = exType?.ToDisplayString(ParamTypeFormat) + ?? TelemetryAttributeNames.System.Exception.MetadataFullName; parameters.Add(new LogParameterInfo("exception", exTypeStr, exceptionExpression)); } @@ -503,9 +503,9 @@ symbol is IFieldSymbol // Group calls that represent the same logical log operation (same attribute, template, params). // Identical calls share one interface method; distinct calls that happen to produce the same // base name from the template get a numeric suffix. - Dictionary signatureToName = new(StringComparer.Ordinal); - HashSet usedNames = new(StringComparer.Ordinal); - List<(LogCallInfo, string)> result = new(calls.Count); + Dictionary signatureToName = [with(StringComparer.Ordinal)]; + HashSet usedNames = [with(StringComparer.Ordinal)]; + List<(LogCallInfo, string)> result = [with(calls.Count)]; foreach (var call in calls) { @@ -582,7 +582,7 @@ static string BuildInterfaceCode(string interfaceName, List<(LogCallInfo Call, s internal static string BuildInterfaceMembers(List<(LogCallInfo Call, string MethodName)> callsWithMethods) { StringBuilder sb = new(); - HashSet emittedSignatures = new(StringComparer.Ordinal); + HashSet emittedSignatures = [with(StringComparer.Ordinal)]; foreach (var (call, methodName) in callsWithMethods) { @@ -788,8 +788,7 @@ static InterfaceDeclarationSyntax ParseInterfaceNode(string code) var root = tree.GetCompilationUnitRoot(); // Try to find the interface inside a namespace or at top-level - var interfaceDecl = - root.DescendantNodes().OfType().FirstOrDefault() + var interfaceDecl = root.DescendantNodes().OfType().FirstOrDefault() ?? throw new InvalidOperationException("Could not parse generated interface from: " + code); // Return as a standalone declaration with appropriate usings attached as leading trivia @@ -813,9 +812,10 @@ SemanticModel semanticModel var loggerVariableRemap = BuildLoggerVariableRemap(classDecl, loggerFields); // Build a map from invocation → new invocation - Dictionary invocationMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary invocationMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var (call, methodName) in callsWithMethods) { @@ -831,9 +831,10 @@ SemanticModel semanticModel } // Build a map from field declarations → new field declarations - Dictionary fieldMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary fieldMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var field in loggerFields) { if (field.FieldDeclaration is null) @@ -844,9 +845,10 @@ SemanticModel semanticModel } // Build a map from property declarations → new property declarations - Dictionary propertyMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary propertyMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var field in loggerFields) { if (field.PropertyDeclaration is null) @@ -891,18 +893,15 @@ SemanticModel semanticModel var ctors = newClassDecl.Members.OfType().ToList(); if (ctors.Count > 0) { - newClassDecl = newClassDecl.ReplaceNodes( - ctors, - (ctor, _) => - { - var filtered = ctor.ParameterList.Parameters.Where(p => - !paramsToRemove.Contains(p.Identifier.Text) - ); - return ctor.WithParameterList( - ctor.ParameterList.WithParameters(SyntaxFactory.SeparatedList(filtered)) - ); - } - ); + newClassDecl = newClassDecl.ReplaceNodes(ctors, (ctor, _) => + { + var filtered = ctor.ParameterList.Parameters.Where(p => + !paramsToRemove.Contains(p.Identifier.Text) + ); + return ctor.WithParameterList( + ctor.ParameterList.WithParameters(SyntaxFactory.SeparatedList(filtered)) + ); + }); } } @@ -960,9 +959,10 @@ static Dictionary BuildParamRewriteMap( SemanticModel semanticModel ) { - Dictionary result = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary result = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; // Match parameters by the exact types of the identified logger fields. // This is more precise than re-detecting all ILogger variants from scratch. @@ -1037,7 +1037,7 @@ List loggerFields ) { HashSet loggerFieldNames = new(loggerFields.Select(f => f.FieldName), StringComparer.Ordinal); - Dictionary remap = new(StringComparer.Ordinal); + Dictionary remap = [with(StringComparer.Ordinal)]; if (classDecl.ParameterList is { } primaryCtorParams) AddParamListRemap(primaryCtorParams.Parameters, loggerFieldNames, remap); diff --git a/src/src/SourceGenerator.Refactorings/ConvertMetricsToTelemetryRefactoringProvider.cs b/src/src/SourceGenerator.Refactorings/ConvertMetricsToTelemetryRefactoringProvider.cs index 2288e242..743e71c9 100644 --- a/src/src/SourceGenerator.Refactorings/ConvertMetricsToTelemetryRefactoringProvider.cs +++ b/src/src/SourceGenerator.Refactorings/ConvertMetricsToTelemetryRefactoringProvider.cs @@ -370,7 +370,7 @@ List fields ) { // Assign a base method name per field, then deduplicate - Dictionary fieldNameMap = new(StringComparer.Ordinal); + Dictionary fieldNameMap = [with(StringComparer.Ordinal)]; foreach (var field in fields) { if (!fieldNameMap.ContainsKey(field.FieldName)) @@ -456,7 +456,7 @@ SemanticModel semanticModel internal static string BuildInterfaceMembers(List<(MetricsCallInfo Call, string MethodName)> callsWithMethods) { StringBuilder sb = new(); - HashSet emitted = new(StringComparer.Ordinal); + HashSet emitted = [with(StringComparer.Ordinal)]; foreach (var (call, methodName) in callsWithMethods) { @@ -554,9 +554,10 @@ SemanticModel semanticModel ) { // Map each invocation to its replacement - Dictionary invocationMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary invocationMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var (call, methodName) in callsWithMethods) { @@ -566,9 +567,10 @@ SemanticModel semanticModel } // Map field declarations - Dictionary fieldMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary fieldMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var field in metricsFields) { if (field.FieldDeclaration is null) @@ -578,9 +580,10 @@ SemanticModel semanticModel } // Map property declarations - Dictionary propertyMap = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary propertyMap = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; foreach (var field in metricsFields) { if (field.PropertyDeclaration is null) @@ -650,9 +653,10 @@ static Dictionary BuildParamRewriteMap( SemanticModel semanticModel ) { - Dictionary result = new( - SyntaxNodeReferenceComparer.Instance - ); + Dictionary result = + [ + with(SyntaxNodeReferenceComparer.Instance), + ]; HashSet fieldTypeNames = new( metricsFields.Select(f => f.TypeSymbol.ToDisplayString()), diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 462f5779..ac52d515 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -10,3 +10,9 @@ TSG3024 | Activity.Usage | Warning | [ActivitySourceOptions require UseActivityS TSG4010 | Metrics.Usage | Warning | [Gauge is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4010) TSG4011 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4011) TSG4012 | Metrics.Usage | Warning | [HistogramBucketBoundaries is not a list of numbers](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg4012) +TSG2022 | Logging.Usage | Error | [params parameters are not supported](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2022) +TSG2023 | Logging.Usage | Error | [ref, out and ref struct parameters are not supported](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2023) +TSG2024 | Logging.Usage | Error | [TagProvider type could not be resolved](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2024) +TSG2025 | Logging.Usage | Error | [TagProvider method could not be found](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2025) +TSG2026 | Logging.Usage | Error | [TagProvider method has an invalid signature](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2026) +TSG2027 | Logging.Usage | Warning | [TagProvider is only supported on logging parameters](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg2027) diff --git a/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs b/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs index 62c6b6d6..283941a5 100644 --- a/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs +++ b/src/src/SourceGenerator/Compatibility/ClosedTypeAttribute.cs @@ -20,11 +20,9 @@ sealed class IsClosedTypeAttribute : Attribute /// a closed class is the constructor that lets a subtype derive from it. /// [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)] - sealed class CompilerFeatureRequiredAttribute : Attribute + sealed class CompilerFeatureRequiredAttribute(string featureName) : Attribute { - public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName; - - public string FeatureName { get; } + public string FeatureName { get; } = featureName; public bool IsOptional { get; init; } } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs index a6b29994..e7120529 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ActivityMethods.cs @@ -106,14 +106,11 @@ out var _ { writer .NewLine() - .IfBlock( - activityVariableName + " != null", - body => - { - EmitTagsOrBaggageParameters(writer, activityVariableName, true, methodTarget, false, output); - EmitTagsOrBaggageParameters(writer, activityVariableName, false, methodTarget, false, output); - } - ); + .IfBlock(activityVariableName + " != null", body => + { + EmitTagsOrBaggageParameters(writer, activityVariableName, true, methodTarget, false, output); + EmitTagsOrBaggageParameters(writer, activityVariableName, false, methodTarget, false, output); + }); } context.CancellationToken.ThrowIfCancellationRequested(); @@ -195,9 +192,8 @@ int kind static void EmitHasListenersTest(CodeWriter writer, ActivityMethodTarget methodTarget) { var returnsVoid = methodTarget.ReturnType.Identity.SpecialType == SpecialType.System_Void; - writer.IfBlock( - "!" + PropertyLibrary.Activities.ActivitySourceFieldName + ".HasListeners()", - body => body.Return(returnsVoid ? null : "null" + (methodTarget.ReturnType.IsNullable ? null : "!")) + writer.IfBlock("!" + PropertyLibrary.Activities.ActivitySourceFieldName + ".HasListeners()", body => + body.Return(returnsVoid ? null : "null" + (methodTarget.ReturnType.IsNullable ? null : "!")) ); writer.NewLine(); diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs index 9c5286af..95302f2d 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.ContextMethods.cs @@ -32,8 +32,8 @@ out var _ return; } - var activityVariableName = - activityParam?.ParameterName ?? TypeLibrary.System.Diagnostics.Activity.StaticMember("Current"); + var activityVariableName = activityParam?.ParameterName + ?? TypeLibrary.System.Diagnostics.Activity.StaticMember("Current"); if (tagsParam != null) { @@ -51,14 +51,11 @@ out var _ EmitHasListenersTest(writer, methodTarget); - writer.IfBlock( - activityVariableName + " != null", - body => - { - EmitTagsOrBaggageParameters(writer, activityVariableName, true, methodTarget, false, output); - EmitTagsOrBaggageParameters(writer, activityVariableName, false, methodTarget, false, output); - } - ); + writer.IfBlock(activityVariableName + " != null", body => + { + EmitTagsOrBaggageParameters(writer, activityVariableName, true, methodTarget, false, output); + EmitTagsOrBaggageParameters(writer, activityVariableName, false, methodTarget, false, output); + }); context.CancellationToken.ThrowIfCancellationRequested(); diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs index acd996f6..131b6e1b 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs @@ -33,8 +33,8 @@ out var statusDescriptionParam return; } - var activityVariableName = - activityParam?.ParameterName ?? (TypeLibrary.System.Diagnostics.Activity + ".Current"); + var activityVariableName = activityParam?.ParameterName + ?? (TypeLibrary.System.Diagnostics.Activity + ".Current"); if (parentContextOrId != null) { output.Context.Diagnostic("Parent context/ Id not allowed on event method, only activities."); @@ -58,53 +58,43 @@ out var statusDescriptionParam EmitHasListenersTest(writer, methodTarget); - writer.IfBlock( - activityVariableName + " != null", - body => + writer.IfBlock(activityVariableName + " != null", body => + { + var exceptionParam = methodTarget.Parameters.FirstOrDefault(m => m.IsException) + ?? methodTarget.Tags.FirstOrDefault(m => m.IsException); + var tagsParameterName = EmitEventTags(writer, methodTarget, activityVariableName, tagsParam, escapeParam); + + var eventVariableName = "activityEvent" + methodTarget.MethodName; + + writer + .NewLine() + .Write(TypeLibrary.System.Diagnostics.ActivityEvent) + .Write(' ') + .Write(eventVariableName) + .Write(" = new ") + // Use explicit type for C# 7.3 compatibility (target-typed new() requires C# 9+) + .Write(TypeLibrary.System.Diagnostics.ActivityEvent) + .Write("(name: ") + .Write(methodTarget.ActivityOrEventName.Wrap()) + // timestamp: + .Write(", timestamp: ") + .Write(timestampParam?.ParameterName ?? "default") + // tags: + .Write(", tags: ") + .Write(tagsParameterName) + .Line(");"); + + writer.NewLine().Write(activityVariableName).Write(".AddEvent(").Write(eventVariableName).Line(");"); + + if (methodTarget.Baggage.Count > 0) { - var exceptionParam = - methodTarget.Parameters.FirstOrDefault(m => m.IsException) - ?? methodTarget.Tags.FirstOrDefault(m => m.IsException); - var tagsParameterName = EmitEventTags( - writer, - methodTarget, - activityVariableName, - tagsParam, - escapeParam - ); - - var eventVariableName = "activityEvent" + methodTarget.MethodName; - - writer - .NewLine() - .Write(TypeLibrary.System.Diagnostics.ActivityEvent) - .Write(' ') - .Write(eventVariableName) - .Write(" = new ") - // Use explicit type for C# 7.3 compatibility (target-typed new() requires C# 9+) - .Write(TypeLibrary.System.Diagnostics.ActivityEvent) - .Write("(name: ") - .Write(methodTarget.ActivityOrEventName.Wrap()) - // timestamp: - .Write(", timestamp: ") - .Write(timestampParam?.ParameterName ?? "default") - // tags: - .Write(", tags: ") - .Write(tagsParameterName) - .Line(");"); - - writer.NewLine().Write(activityVariableName).Write(".AddEvent(").Write(eventVariableName).Line(");"); - - if (methodTarget.Baggage.Count > 0) - { - writer.NewLine(); + writer.NewLine(); - EmitTagsOrBaggageParameters(writer, activityVariableName, false, methodTarget, false, output); - } - - EmitSetStatus(writer, methodTarget, activityVariableName, statusDescriptionParam, exceptionParam); + EmitTagsOrBaggageParameters(writer, activityVariableName, false, methodTarget, false, output); } - ); + + EmitSetStatus(writer, methodTarget, activityVariableName, statusDescriptionParam, exceptionParam); + }); context.CancellationToken.ThrowIfCancellationRequested(); @@ -140,8 +130,7 @@ static string EmitEventTags( writer.Line(");"); - var useRecordedExceptionRules = - methodTarget.EventAttribute?.UseRecordExceptionRules + var useRecordedExceptionRules = methodTarget.EventAttribute?.UseRecordExceptionRules ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; var escapeValue = escapeParam?.ParameterName ?? "true"; @@ -153,11 +142,9 @@ void EmitTag() { if (methodTarget.ActivityOrEventName == PropertyLibrary.Activities.Tag_ExceptionEventName) { - writer.IfBlock( - tagParam.ParameterName + " != null", - body => - // We want the details inside of the current event. - EmitExceptionParam(writer, tagsListVariableName, escapeValue, tagParam.ParameterName) + writer.IfBlock(tagParam.ParameterName + " != null", body => + // We want the details inside of the current event. + EmitExceptionParam(writer, tagsListVariableName, escapeValue, tagParam.ParameterName) ); } else diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs index 5feb504f..110cdf70 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs @@ -96,9 +96,8 @@ string activitySourceName ) ) { - writer.IfBlock( - factoryParameter + " == null", - body => body.Throw($"new global::System.ArgumentNullException(nameof({factoryParameter}))") + writer.IfBlock(factoryParameter + " == null", body => + body.Throw($"new global::System.ArgumentNullException(nameof({factoryParameter}))") ); writer.NewLine(); @@ -113,13 +112,15 @@ string activitySourceName .Write(activitySourceName.Wrap()) .Write(')'); - // .NET 11's ActivitySourceOptions carries the source's version and telemetry schema URL. They are - // emitted only when the assembly set them, so a source whose assembly set neither keeps the - // single-argument constructor. - var version = output.Target.ActivitySourceGenerationAttribute?.Version; + // .NET 11's ActivitySourceOptions carries the source's version, telemetry schema URL and tags. They are + // emitted only when set, so a source whose assembly set none keeps the single-argument constructor. + // The version falls back to the assembly version, resolved once per compilation on the capabilities. + var version = output.Target.ActivitySourceGenerationAttribute?.Version + ?? output.Context.Capabilities.AssemblyVersion; var telemetrySchemaUrl = output.Target.ActivitySourceGenerationAttribute?.TelemetrySchemaUrl; + var tags = SharedHelpers.MergeTags(output.Target.ActivitySourceGenerationAttribute?.Tags, null); - if (!string.IsNullOrWhiteSpace(version) || !string.IsNullOrWhiteSpace(telemetrySchemaUrl)) + if (!string.IsNullOrWhiteSpace(version) || !string.IsNullOrWhiteSpace(telemetrySchemaUrl) || !tags.IsEmpty) { writer .Write(' ') @@ -131,6 +132,32 @@ string activitySourceName if (!string.IsNullOrWhiteSpace(telemetrySchemaUrl)) body.Write("TelemetrySchemaUrl = ").Line(telemetrySchemaUrl!.Wrap() + ","); + + if (!tags.IsEmpty) + { + body.Write( + "Tags = new global::System.Collections.Generic.KeyValuePair[] {" + ); + var first = true; + foreach (var tag in tags) + { + var separator = tag.IndexOf('='); + if (separator <= 0) + continue; + + if (!first) + body.Write(", "); + + first = false; + body.Write("new(") + .Write(tag.Substring(0, separator).Wrap()) + .Write(", ") + .Write(tag.Substring(separator + 1).Wrap()) + .Write(')'); + } + + body.Line(" },"); + } }) .Line("});"); } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs index 215e0e88..4fd10ee4 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Methods.cs @@ -67,42 +67,76 @@ SourceProductionContext context ) ) { - writer.IfBlock("activity == null || exception == null", static body => body.Return()); + EmitRecordExceptionBody(writer, output.Context.Capabilities.SupportsActivityAddException); + } - writer.NewLine(); + writer.NewLine(); + } - const string tagsListVariableName = "tagsCollection"; - writer.Assignment( - TypeLibrary.System.Diagnostics.ActivityTagsCollection, - tagsListVariableName, - "new " + TypeLibrary.System.Diagnostics.ActivityTagsCollection + "()" - ); + /// + /// Writes the body of the generated exception-recording helper. + /// + /// + /// Extracted so both capability states have a test that needs no compilation: when the compilation can resolve + /// Activity.AddException the framework builds the "exception" event and writes + /// exception.type, exception.message and exception.stacktrace itself, so this method supplies + /// only exception.escaped through the TagList overload. Adding the other three here would duplicate + /// the framework's tags. Otherwise the event is built by hand, which is what net48 and any pinned + /// DiagnosticSource older than 9 need. + /// + /// The writer to emit into. + /// Whether Activity.AddException is available. + internal static void EmitRecordExceptionBody(CodeWriter writer, bool supportsActivityAddException) + { + writer.IfBlock("activity == null || exception == null", static body => body.Return()); - EmitExceptionParam(writer, tagsListVariableName, "escape", "exception"); + writer.NewLine(); + + if (supportsActivityAddException) + { + const string tagsVariableName = "exceptionTags"; - const string eventVariableName = "recordExceptionEvent"; + writer.Assignment(TypeLibrary.System.Diagnostics.TagList, tagsVariableName, "default"); writer + .MethodCallOn(tagsVariableName, "Add", PropertyLibrary.Activities.Tag_ExceptionEscaped.Wrap(), "escape") .NewLine() - .Write(TypeLibrary.System.Diagnostics.ActivityEvent) - .Write(' ') - .Write(eventVariableName) - .Write(" = new ") - .Write(TypeLibrary.System.Diagnostics.ActivityEvent) - // name: - .Write("(name: ") - .Write(PropertyLibrary.Activities.Tag_ExceptionEventName.Wrap()) - // timestamp: - .Write(", timestamp: default") - // tags: - .Write(", tags: ") - .Write(tagsListVariableName) - .Line(");"); - - writer.NewLine().Write("activity.AddEvent(").Write(eventVariableName).Line(");"); + .Write("activity.AddException(exception, ") + .Write(tagsVariableName) + .Line(", default);"); + + return; } - writer.NewLine(); + const string tagsListVariableName = "tagsCollection"; + writer.Assignment( + TypeLibrary.System.Diagnostics.ActivityTagsCollection, + tagsListVariableName, + "new " + TypeLibrary.System.Diagnostics.ActivityTagsCollection + "()" + ); + + EmitExceptionParam(writer, tagsListVariableName, "escape", "exception"); + + const string eventVariableName = "recordExceptionEvent"; + + writer + .NewLine() + .Write(TypeLibrary.System.Diagnostics.ActivityEvent) + .Write(' ') + .Write(eventVariableName) + .Write(" = new ") + .Write(TypeLibrary.System.Diagnostics.ActivityEvent) + // name: + .Write("(name: ") + .Write(PropertyLibrary.Activities.Tag_ExceptionEventName.Wrap()) + // timestamp: + .Write(", timestamp: default") + // tags: + .Write(", tags: ") + .Write(tagsListVariableName) + .Line(");"); + + writer.NewLine().Write("activity.AddEvent(").Write(eventVariableName).Line(");"); } static void EmitExceptionParam( @@ -163,7 +197,7 @@ static void EmitThrowStub(CodeWriter writer, ActivityMethodTarget methodTarget) p.ParameterType )), ], - GenericTypes = [.. methodTarget.TypeParameters], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), ExpressionBody = "throw new global::System.NotSupportedException()", } ) @@ -250,6 +284,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) @@ -304,6 +339,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) @@ -386,6 +422,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs index 1b5e2f6a..9baf6ee2 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs @@ -21,8 +21,8 @@ static void EmitTagsOrBaggageParameters( if (parameters.Count == 0) return; - var useRecordedExceptionRules = - eventAttribute?.UseRecordExceptionRules ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; + var useRecordedExceptionRules = eventAttribute?.UseRecordExceptionRules + ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; void EmitParameter(ActivityBasedParameterTarget param) { diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs index 4974b0c8..c8de928f 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.cs @@ -33,7 +33,8 @@ public static void GenerateImplementation(ActivityOutputContext output, SourcePr target.GenerationType, target.ClassNameToGenerate, target.InterfaceType, - TypeDeclarationAccessibility.Internal + TypeDeclarationAccessibility.Internal, + target.InterfaceTypeParameters ) ) { diff --git a/src/src/SourceGenerator/Emitters/DependencyInjectionClassEmitter.cs b/src/src/SourceGenerator/Emitters/DependencyInjectionClassEmitter.cs index cf98428f..657768e1 100644 --- a/src/src/SourceGenerator/Emitters/DependencyInjectionClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/DependencyInjectionClassEmitter.cs @@ -137,6 +137,23 @@ CancellationToken token ) ) { + if (interfaceType.Identity.GenericArity > 0) + { + // An open generic cannot be named with type arguments here, and this static extension cannot repeat the + // class's type parameters, so the registration is the open-generic form. + var identity = interfaceType.Identity; + var openInterfaceName = + "global::" + (identity.Namespace is { } ns ? ns + "." : string.Empty) + identity.Name + "<>"; + var openClassName = + "global::" + BuildImplQualifiedName(telemetryNamesNamespace, interfaceType, className) + "<>"; + + writer.Return( + "services.AddSingleton(typeof(" + openInterfaceName + "), typeof(" + openClassName + "))" + ); + + return; + } + writer.Return( "services.AddSingleton<" + interfaceType.RenderFullName diff --git a/src/src/SourceGenerator/Emitters/EmitterHelpers.cs b/src/src/SourceGenerator/Emitters/EmitterHelpers.cs index 291f8a6d..04c640a4 100644 --- a/src/src/SourceGenerator/Emitters/EmitterHelpers.cs +++ b/src/src/SourceGenerator/Emitters/EmitterHelpers.cs @@ -47,7 +47,8 @@ public static CodeWriter.BlockScope EmitClassScope( GenerationType generationType, string className, TypeReference interfaceType, - TypeDeclarationAccessibility accessibility + TypeDeclarationAccessibility accessibility, + EquatableArray interfaceTypeParameters = default ) { ImmutableArray attributes = []; @@ -60,6 +61,8 @@ TypeDeclarationAccessibility accessibility IncludeGeneratedAttributes = false, Interfaces = [interfaceType], Attributes = attributes, + // A generic interface makes the generated class generic over the same type parameters. + GenericTypes = TypeParameterHelpers.ToOptions(interfaceTypeParameters), } ); } diff --git a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs index dfd377a8..2a6dfca9 100644 --- a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs @@ -13,22 +13,18 @@ static class GeneratedTypesEmitter static IEnumerable<(TypeIdentity Type, Action Emitter)> GetEmitters() { // Telemetry Shared - yield return ( - TypeLibrary.Purview.Telemetry.TagAttribute, - (writer, type) => - WriteTagLikeAttribute(writer, type, "Marks a parameter as a tag for an activity, event or instrument.") + yield return (TypeLibrary.Purview.Telemetry.TagAttribute, (writer, type) => + WriteTagLikeAttribute(writer, type, "Marks a parameter as a tag for an activity, event or instrument.") ); - yield return ( - TypeLibrary.Purview.Telemetry.ExcludeAttribute, - (writer, type) => - WriteSimpleAttribute( - writer, - type, - AttributeTargets.Method, - includeSuppressMessage: false, - "Marks a method to be excluded from telemetry generation." - ) + yield return (TypeLibrary.Purview.Telemetry.ExcludeAttribute, (writer, type) => + WriteSimpleAttribute( + writer, + type, + AttributeTargets.Method, + includeSuppressMessage: false, + "Marks a method to be excluded from telemetry generation." + ) ); yield return (TypeLibrary.Purview.Telemetry.TelemetryGenerationAttribute, WriteTelemetryGenerationAttribute); yield return ( @@ -40,10 +36,8 @@ static class GeneratedTypesEmitter yield return (TypeLibrary.Purview.Telemetry.ExcludeTargetsAttribute, WriteExcludeTargetsAttribute); // Activities - yield return ( - TypeLibrary.Purview.Telemetry.BaggageAttribute, - (writer, type) => - WriteTagLikeAttribute(writer, type, "Marks a parameter as baggage to be attached to an activity.") + yield return (TypeLibrary.Purview.Telemetry.BaggageAttribute, (writer, type) => + WriteTagLikeAttribute(writer, type, "Marks a parameter as baggage to be attached to an activity.") ); yield return ( TypeLibrary.Purview.Telemetry.ActivitySourceGenerationAttribute, @@ -52,38 +46,32 @@ static class GeneratedTypesEmitter yield return (TypeLibrary.Purview.Telemetry.ActivitySourceAttribute, WriteActivitySourceAttribute); yield return (TypeLibrary.Purview.Telemetry.ActivityAttribute, WriteActivityAttribute); yield return (TypeLibrary.Purview.Telemetry.EventAttribute, WriteEventAttribute); - yield return ( - TypeLibrary.Purview.Telemetry.ContextAttribute, - (writer, type) => - WriteSimpleAttribute( - writer, - type, - AttributeTargets.Method, - includeSuppressMessage: false, - "Marks a parameter as an activity context." - ) + yield return (TypeLibrary.Purview.Telemetry.ContextAttribute, (writer, type) => + WriteSimpleAttribute( + writer, + type, + AttributeTargets.Method, + includeSuppressMessage: false, + "Marks a parameter as an activity context." + ) ); - yield return ( - TypeLibrary.Purview.Telemetry.EscapeAttribute, - (writer, type) => - WriteSimpleAttribute( - writer, - type, - AttributeTargets.Parameter, - includeSuppressMessage: false, - "Marks a parameter as the escape flag for a recorded exception." - ) + yield return (TypeLibrary.Purview.Telemetry.EscapeAttribute, (writer, type) => + WriteSimpleAttribute( + writer, + type, + AttributeTargets.Parameter, + includeSuppressMessage: false, + "Marks a parameter as the escape flag for a recorded exception." + ) ); - yield return ( - TypeLibrary.Purview.Telemetry.StatusDescriptionAttribute, - (writer, type) => - WriteSimpleAttribute( - writer, - type, - AttributeTargets.Parameter, - includeSuppressMessage: false, - "Marks a parameter as the status description of an activity or event." - ) + yield return (TypeLibrary.Purview.Telemetry.StatusDescriptionAttribute, (writer, type) => + WriteSimpleAttribute( + writer, + type, + AttributeTargets.Parameter, + includeSuppressMessage: false, + "Marks a parameter as the status description of an activity or event." + ) ); // Logging @@ -93,92 +81,70 @@ static class GeneratedTypesEmitter yield return (TypeLibrary.Purview.Telemetry.LogPrefixType, WriteLogPrefixTypeEnum); yield return (TypeLibrary.Purview.Telemetry.LoggerGenerationMode, WriteLoggerGenerationModeEnum); yield return (TypeLibrary.Purview.Telemetry.ExpandEnumerableAttribute, WriteExpandEnumerableAttribute); - yield return ( - TypeLibrary.Purview.Telemetry.TraceAttribute, - (writer, type) => WriteSpecificLogAttribute(writer, type, "Marks a method as a trace-level log method.") + yield return (TypeLibrary.Purview.Telemetry.TraceAttribute, (writer, type) => + WriteSpecificLogAttribute(writer, type, "Marks a method as a trace-level log method.") ); - yield return ( - TypeLibrary.Purview.Telemetry.DebugAttribute, - (writer, type) => WriteSpecificLogAttribute(writer, type, "Marks a method as a debug-level log method.") + yield return (TypeLibrary.Purview.Telemetry.DebugAttribute, (writer, type) => + WriteSpecificLogAttribute(writer, type, "Marks a method as a debug-level log method.") ); - yield return ( - TypeLibrary.Purview.Telemetry.InfoAttribute, - (writer, type) => WriteSpecificLogAttribute(writer, type, "Marks a method as an informational log method.") + yield return (TypeLibrary.Purview.Telemetry.InfoAttribute, (writer, type) => + WriteSpecificLogAttribute(writer, type, "Marks a method as an informational log method.") ); - yield return ( - TypeLibrary.Purview.Telemetry.WarningAttribute, - (writer, type) => WriteSpecificLogAttribute(writer, type, "Marks a method as a warning-level log method.") + yield return (TypeLibrary.Purview.Telemetry.WarningAttribute, (writer, type) => + WriteSpecificLogAttribute(writer, type, "Marks a method as a warning-level log method.") ); - yield return ( - TypeLibrary.Purview.Telemetry.ErrorAttribute, - (writer, type) => WriteSpecificLogAttribute(writer, type, "Marks a method as an error-level log method.") + yield return (TypeLibrary.Purview.Telemetry.ErrorAttribute, (writer, type) => + WriteSpecificLogAttribute(writer, type, "Marks a method as an error-level log method.") ); - yield return ( - TypeLibrary.Purview.Telemetry.CriticalAttribute, - (writer, type) => WriteSpecificLogAttribute(writer, type, "Marks a method as a critical-level log method.") + yield return (TypeLibrary.Purview.Telemetry.CriticalAttribute, (writer, type) => + WriteSpecificLogAttribute(writer, type, "Marks a method as a critical-level log method.") ); // Metrics yield return (TypeLibrary.Purview.Telemetry.MeterGenerationAttribute, WriteMeterGenerationAttribute); yield return (TypeLibrary.Purview.Telemetry.MeterAttribute, WriteMeterAttribute); yield return (TypeLibrary.Purview.Telemetry.MeterNameGenerationType, WriteMeterNameGenerationTypeEnum); - yield return ( - TypeLibrary.Purview.Telemetry.InstrumentMeasurementAttribute, - (writer, type) => - WriteSimpleAttribute( - writer, - type, - AttributeTargets.Parameter, - includeSuppressMessage: false, - "Marks a parameter as the measurement value of an instrument." - ) + yield return (TypeLibrary.Purview.Telemetry.InstrumentMeasurementAttribute, (writer, type) => + WriteSimpleAttribute( + writer, + type, + AttributeTargets.Parameter, + includeSuppressMessage: false, + "Marks a parameter as the measurement value of an instrument." + ) ); - yield return ( - TypeLibrary.Purview.Telemetry.AutoCounterAttribute, - (writer, type) => - WriteAutoCounterAttribute(writer, type, "Marks a method as an auto-incrementing counter instrument.") + yield return (TypeLibrary.Purview.Telemetry.AutoCounterAttribute, (writer, type) => + WriteAutoCounterAttribute(writer, type, "Marks a method as an auto-incrementing counter instrument.") ); - yield return ( - TypeLibrary.Purview.Telemetry.CounterAttribute, - (writer, type) => WriteCounterLikeAttribute(writer, type, "Marks a method as a counter instrument.") + yield return (TypeLibrary.Purview.Telemetry.CounterAttribute, (writer, type) => + WriteCounterLikeAttribute(writer, type, "Marks a method as a counter instrument.", writeAutoIncrement: true) ); - yield return ( - TypeLibrary.Purview.Telemetry.UpDownCounterAttribute, - (writer, type) => - WriteCounterLikeAttribute(writer, type, "Marks a method as an up-down counter instrument.") + yield return (TypeLibrary.Purview.Telemetry.UpDownCounterAttribute, (writer, type) => + WriteCounterLikeAttribute(writer, type, "Marks a method as an up-down counter instrument.") ); - yield return ( - TypeLibrary.Purview.Telemetry.HistogramAttribute, - (writer, type) => - WriteCounterLikeAttribute( - writer, - type, - "Marks a method as a histogram instrument.", - writeHistogramBucketBoundaries: true - ) + yield return (TypeLibrary.Purview.Telemetry.HistogramAttribute, (writer, type) => + WriteCounterLikeAttribute( + writer, + type, + "Marks a method as a histogram instrument.", + writeHistogramBucketBoundaries: true + ) ); - yield return ( - TypeLibrary.Purview.Telemetry.GaugeAttribute, - (writer, type) => WriteGaugeAttribute(writer, type, "Marks a method as a gauge instrument.") + yield return (TypeLibrary.Purview.Telemetry.GaugeAttribute, (writer, type) => + WriteGaugeAttribute(writer, type, "Marks a method as a gauge instrument.") ); - yield return ( - TypeLibrary.Purview.Telemetry.ObservableCounterAttribute, - (writer, type) => - WriteObservableCounterLikeAttribute(writer, type, "Marks a method as an observable counter instrument.") + yield return (TypeLibrary.Purview.Telemetry.ObservableCounterAttribute, (writer, type) => + WriteObservableCounterLikeAttribute(writer, type, "Marks a method as an observable counter instrument.") ); - yield return ( - TypeLibrary.Purview.Telemetry.ObservableUpDownCounterAttribute, - (writer, type) => - WriteObservableCounterLikeAttribute( - writer, - type, - "Marks a method as an observable up-down counter instrument." - ) + yield return (TypeLibrary.Purview.Telemetry.ObservableUpDownCounterAttribute, (writer, type) => + WriteObservableCounterLikeAttribute( + writer, + type, + "Marks a method as an observable up-down counter instrument." + ) ); - yield return ( - TypeLibrary.Purview.Telemetry.ObservableGaugeAttribute, - (writer, type) => - WriteObservableCounterLikeAttribute(writer, type, "Marks a method as an observable gauge instrument.") + yield return (TypeLibrary.Purview.Telemetry.ObservableGaugeAttribute, (writer, type) => + WriteObservableCounterLikeAttribute(writer, type, "Marks a method as an observable gauge instrument.") ); } @@ -400,21 +366,19 @@ static void WriteNullableStringProperty(CodeWriter writer, string name, string s throw new ArgumentException("Summary must be provided for public properties.", nameof(summary)); #endif - writer.HashDefines( - "NET48_OR_GREATER", - hashWriter => - hashWriter - .XmlSummary(summary) - .Property( - new(name, TypeLibrary.System.String, TypeDeclarationAccessibility.Public) { HasSetter = true } - ) - .HashElse() - .Property( - new(name, TypeLibrary.System.String.MakeNullable(writer), TypeDeclarationAccessibility.Public) - { - HasSetter = true, - } - ) + writer.HashDefines("NET48_OR_GREATER", hashWriter => + hashWriter + .XmlSummary(summary) + .Property( + new(name, TypeLibrary.System.String, TypeDeclarationAccessibility.Public) { HasSetter = true } + ) + .HashElse() + .Property( + new(name, TypeLibrary.System.String.MakeNullable(writer), TypeDeclarationAccessibility.Public) + { + HasSetter = true, + } + ) ); } @@ -736,9 +700,8 @@ static void WriteActivitySourceGenerationAttribute(CodeWriter writer, TypeIdenti }, ctor => { - ctor.IfBlock( - "string.IsNullOrWhiteSpace(name)", - static body => body.Throw("new System.ArgumentNullException(nameof(name))") + ctor.IfBlock("string.IsNullOrWhiteSpace(name)", static body => + body.Throw("new System.ArgumentNullException(nameof(name))") ); ctor.Assignment("Name", "name"); ctor.Assignment("DefaultToTags", "defaultToTags"); @@ -804,6 +767,13 @@ static void WriteActivitySourceGenerationAttribute(CodeWriter writer, TypeIdenti "The telemetry schema URL passed to System.Diagnostics.ActivitySourceOptions for factory-created " + "sources. Ignored, and reported as TSG3024, unless UseActivitySourceFactory is set." ); + WriteNullableStringProperty( + body, + "Tags", + "The tags passed to System.Diagnostics.ActivitySourceOptions for factory-created sources, as a " + + "comma-separated list of key=value pairs. Ignored, and reported as TSG3024, unless " + + "UseActivitySourceFactory is set." + ); }, "Specifies the default activity source generation behaviour for an assembly." ); @@ -823,11 +793,8 @@ static void WriteActivitySourceAttribute(CodeWriter writer, TypeIdentity type) $"Constructs a new instance of the {XmlSee("ActivitySourceAttribute")}." ); - WriteNameConstructor( - body, - type, - $"Constructs a new instance specifying the {XmlSee("Name")}.", - w => w.XmlParam("name", $"The {XmlSee("Name")}.") + WriteNameConstructor(body, type, $"Constructs a new instance specifying the {XmlSee("Name")}.", w => + w.XmlParam("name", $"The {XmlSee("Name")}.") ); WriteNullableStringProperty(body, "Name", "Optional. Gets the name of the activity source."); @@ -877,11 +844,8 @@ static void WriteActivityAttribute(CodeWriter writer, TypeIdentity type) { WriteEmptyConstructor(body, type, $"Constructs a new instance of the {XmlSee("ActivityAttribute")}."); - WriteNameConstructor( - body, - type, - $"Constructs a new instance specifying the {XmlSee("Name")}.", - w => w.XmlParam("name", $"The {XmlSee("Name")}.") + WriteNameConstructor(body, type, $"Constructs a new instance specifying the {XmlSee("Name")}.", w => + w.XmlParam("name", $"The {XmlSee("Name")}.") ); body.XmlSummary($"Constructs a new instance specifying the {XmlSee("Kind")}.") @@ -1096,9 +1060,8 @@ static void WriteLoggerAttribute(CodeWriter writer, TypeIdentity type) { ctor.Assignment("DefaultLevel", "defaultLevel"); ctor.Assignment("CustomPrefix", "customPrefix"); - ctor.IfBlock( - "!string.IsNullOrWhiteSpace(CustomPrefix)", - block => block.Assignment("PrefixType", $"{logPrefixType.RenderFullName}.Custom") + ctor.IfBlock("!string.IsNullOrWhiteSpace(CustomPrefix)", block => + block.Assignment("PrefixType", $"{logPrefixType.RenderFullName}.Custom") ); } ); @@ -1225,6 +1188,12 @@ static void WriteLogAttribute(CodeWriter writer, TypeIdentity type) TypeLibrary.Purview.Telemetry.LoggerGenerationMode, "Gets or sets the log generation mode used for the log entry." ); + WritePublicProperty( + body, + "SkipEnabledCheck", + TypeLibrary.System.Boolean, + "Skips the generated IsEnabled check when the caller has already checked the level, as [LoggerMessage(SkipEnabledCheck = true)] does." + ); }, wrapInExcludeLoggingGuard: true, summary: "Marks a method as a log method." @@ -1313,6 +1282,12 @@ static void WriteSpecificLogAttribute(CodeWriter writer, TypeIdentity type, stri TypeLibrary.Purview.Telemetry.LoggerGenerationMode, "Gets or sets the log generation mode used for the log entry." ); + WritePublicProperty( + body, + "SkipEnabledCheck", + TypeLibrary.System.Boolean, + "Skips the generated IsEnabled check when the caller has already checked the level, as [LoggerMessage(SkipEnabledCheck = true)] does." + ); }, wrapInExcludeLoggingGuard: true, summary: summary @@ -1357,7 +1332,6 @@ static void WriteExpandEnumerableAttribute(CodeWriter writer, TypeIdentity type) // ------------------------------------------------------------------------------------------- // Metrics templates // ------------------------------------------------------------------------------------------- - static void WriteMeterGenerationAttribute(CodeWriter writer, TypeIdentity type) { var nameGenerationType = TypeLibrary.Purview.Telemetry.MeterNameGenerationType; @@ -1438,6 +1412,16 @@ static void WriteMeterGenerationAttribute(CodeWriter writer, TypeIdentity type) "Determines whether tag keys are lowercased.", "true" ); + WriteNullableStringProperty( + body, + "Version", + "Gets or sets the meter version passed to MeterOptions.Version (and to the Meter constructor when no IMeterFactory is available)." + ); + WriteNullableStringProperty( + body, + "Tags", + "Gets or sets the meter tags passed to MeterOptions.Tags, as a comma-separated list of key=value pairs." + ); }, "Specifies the default meter generation behaviour for an assembly." ); @@ -1481,6 +1465,16 @@ static void WriteMeterAttribute(CodeWriter writer, TypeIdentity type) "Determines whether tag keys are lowercased.", "true" ); + WriteNullableStringProperty( + body, + "Version", + "Gets or sets the meter version passed to MeterOptions.Version (and to the Meter constructor when no IMeterFactory is available)." + ); + WriteNullableStringProperty( + body, + "Tags", + "Gets or sets the meter tags passed to MeterOptions.Tags, as a comma-separated list of key=value pairs." + ); }, "Marks an interface as a meter." ); @@ -1518,7 +1512,8 @@ static void WriteCounterLikeAttribute( CodeWriter writer, TypeIdentity type, string summary, - bool writeHistogramBucketBoundaries = false + bool writeHistogramBucketBoundaries = false, + bool writeAutoIncrement = false ) { #if DEUBG @@ -1533,30 +1528,41 @@ static void WriteCounterLikeAttribute( body => { WriteEmptyConstructor(body, type, $"Constructs a new instance of the {XmlSee(type.Name)}."); - body.XmlSummary( - $"Constructs a new instance specifying whether the counter auto-increments ({XmlSee("AutoIncrement")})." - ) - .XmlParam("autoIncrement", $"Whether the counter auto-increments ({XmlSee("AutoIncrement")}).") - .Constructor( - new(type.Name, TypeDeclarationAccessibility.Public) - { - Parameters = [new("autoIncrement", TypeLibrary.System.Boolean)], - }, - ctor => ctor.Assignment("AutoIncrement", "autoIncrement") - ); + if (writeAutoIncrement) + { + body.XmlSummary( + $"Constructs a new instance specifying whether the counter auto-increments ({XmlSee("AutoIncrement")})." + ) + .XmlParam("autoIncrement", $"Whether the counter auto-increments ({XmlSee("AutoIncrement")}).") + .Constructor( + new(type.Name, TypeDeclarationAccessibility.Public) + { + Parameters = [new("autoIncrement", TypeLibrary.System.Boolean)], + }, + ctor => ctor.Assignment("AutoIncrement", "autoIncrement") + ); + } + WriteNameUnitDescriptionConstructor( body, type, - appendAutoIncrement: true, - summary: $"Constructs a new instance specifying the {XmlSee("Name")}, {XmlSee("Unit")}, {XmlSee("Description")} and whether the counter auto-increments." + appendAutoIncrement: writeAutoIncrement, + summary: writeAutoIncrement + ? $"Constructs a new instance specifying the {XmlSee("Name")}, {XmlSee("Unit")}, {XmlSee("Description")} and whether the counter auto-increments." + : $"Constructs a new instance specifying the {XmlSee("Name")}, {XmlSee("Unit")} and {XmlSee("Description")}." ); - WritePublicProperty( - body, - "AutoIncrement", - TypeLibrary.System.Boolean, - "Determines whether the counter auto-increments." - ); + // AutoIncrement belongs to [Counter] alone: [UpDownCounter] and [Histogram] hard-code it to false in + // their instrument records, so a settable property here was inert surface a consumer could set and + // never see take effect. + if (writeAutoIncrement) + WritePublicProperty( + body, + "AutoIncrement", + TypeLibrary.System.Boolean, + "Determines whether the counter auto-increments." + ); + WriteNullableStringProperty(body, "Name", "Gets or sets the name of the instrument."); WriteNullableStringProperty(body, "Unit", "Gets or sets the measurement unit of the instrument."); WriteNullableStringProperty(body, "Description", "Gets or sets the description of the instrument."); @@ -1619,12 +1625,6 @@ static void WriteObservableCounterLikeAttribute(CodeWriter writer, TypeIdentity appendThrowOnAlreadyInitialized: true, summary: $"Constructs a new instance specifying the {XmlSee("Name")}, {XmlSee("Unit")}, {XmlSee("Description")} and whether initializing twice throws." ); - WritePublicProperty( - body, - "AutoIncrement", - TypeLibrary.System.Boolean, - "Determines whether the counter auto-increments." - ); WriteNullableStringProperty(body, "Name", "Gets or sets the name of the instrument."); WriteNullableStringProperty(body, "Unit", "Gets or sets the measurement unit of the instrument."); WriteNullableStringProperty(body, "Description", "Gets or sets the description of the instrument."); @@ -1747,45 +1747,41 @@ static void WriteNamingConventionEnum(CodeWriter writer, TypeIdentity type) static void WriteLogPrefixTypeEnum(CodeWriter writer, TypeIdentity type) { - writer.HashDefines( - "!EXCLUDE_PURVIEW_TELEMETRY_LOGGING", - hashWriter => - hashWriter - .FileScopedNamespace(TypeLibrary.Purview.Telemetry.Namespace) - .XmlSummary("Determines the mode used to generate or override the prefix for the log entry.") - .Enum( - type.Name, - TypeDeclarationAccessibility.Public, - fields: - [ - new("Default", 0, "Uses the default log prefix."), - new("Interface", 1, "Uses the interface name as the log prefix."), - new("Class", 2, "Uses the class name as the log prefix."), - new("Custom", 3, "Uses a custom log prefix."), - new("TrimmedClassName", 4, "Uses the trimmed class name as the log prefix."), - ] - ) + writer.HashDefines("!EXCLUDE_PURVIEW_TELEMETRY_LOGGING", hashWriter => + hashWriter + .FileScopedNamespace(TypeLibrary.Purview.Telemetry.Namespace) + .XmlSummary("Determines the mode used to generate or override the prefix for the log entry.") + .Enum( + type.Name, + TypeDeclarationAccessibility.Public, + fields: + [ + new("Default", 0, "Uses the default log prefix."), + new("Interface", 1, "Uses the interface name as the log prefix."), + new("Class", 2, "Uses the class name as the log prefix."), + new("Custom", 3, "Uses a custom log prefix."), + new("TrimmedClassName", 4, "Uses the trimmed class name as the log prefix."), + ] + ) ); } static void WriteLoggerGenerationModeEnum(CodeWriter writer, TypeIdentity type) { - writer.HashDefines( - "!EXCLUDE_PURVIEW_TELEMETRY_LOGGING", - hashWriter => - hashWriter - .FileScopedNamespace(TypeLibrary.Purview.Telemetry.Namespace) - .XmlSummary("Controls the generation mode used for log methods.") - .Enum( - type.Name, - TypeDeclarationAccessibility.Public, - fields: - [ - new("Auto", 0, "Automatically selects the log generation mode."), - new("V1", 1, "Uses the first-generation log implementation."), - new("V2", 2, "Uses the second-generation log implementation."), - ] - ) + writer.HashDefines("!EXCLUDE_PURVIEW_TELEMETRY_LOGGING", hashWriter => + hashWriter + .FileScopedNamespace(TypeLibrary.Purview.Telemetry.Namespace) + .XmlSummary("Controls the generation mode used for log methods.") + .Enum( + type.Name, + TypeDeclarationAccessibility.Public, + fields: + [ + new("Auto", 0, "Automatically selects the log generation mode."), + new("V1", 1, "Uses the first-generation log implementation."), + new("V2", 2, "Uses the second-generation log implementation."), + ] + ) ); } diff --git a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs index e2968a6d..b3368528 100644 --- a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.Methods.cs @@ -126,6 +126,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) @@ -137,7 +138,7 @@ SourceProductionContext context // exception and output it as the exception parameter in // the Log method. - List existingParamNames = new(methodTarget.Parameters.Count); + List existingParamNames = [with(methodTarget.Parameters.Count)]; foreach (var param in methodTarget.Parameters) { existingParamNames.Add(param.Name); @@ -153,11 +154,13 @@ SourceProductionContext context // First check if the the Log Level is enabled. // if (!_logger.IsEnabled(LogLevel.Information))) // { return; }; - // ...but only if it's not been scoped. - writer.IfBlock( - "!" + PropertyLibrary.Logging.LoggerFieldName + ".IsEnabled(" + methodTarget.MSLevel + ")", - static body => body.Return() - ); + // ...but only if it's not been scoped, and not when the method opted out with + // [Log(SkipEnabledCheck = true)] because the caller already checked the level. + if (!methodTarget.SkipEnabledCheck) + writer.IfBlock( + "!" + PropertyLibrary.Logging.LoggerFieldName + ".IsEnabled(" + methodTarget.MSLevel + ")", + static body => body.Return() + ); writer.NewLine(); } @@ -511,6 +514,12 @@ SourceProductionContext context existingParamNames ); } + + if (parameter.TagProvider is { } tagProvider) + { + postSetProperties ??= []; + postSetProperties.Add(OutputTagProvider(stateVarName, parameter, tagProvider, existingParamNames)); + } } if (postSetProperties != null) @@ -544,7 +553,7 @@ List existingParamNames { context.CancellationToken.ThrowIfCancellationRequested(); - var logPropertyValue = $"{parameter.Name}?.{logProperty.PropertyName}"; + var logPropertyValue = parameter.Name + logProperty.ValueAccessor; var logPropertyName = logProperty.PropertyName; if (!parameter.LogPropertiesAttribute!.Value.OmitReferenceName) { @@ -558,9 +567,8 @@ List existingParamNames var tmpVarName = FindUniqueName("tmp", existingParamNames); logPropertiesWriter.Write("{"); logPropertiesWriter.Write("var ").Write(tmpVarName).Write(" = ").Write(logPropertyValue).Line(";"); - logPropertiesWriter.IfBlock( - tmpVarName + " != null", - body => OutputState(body, stateVarName, logPropertyName.Wrap(), tmpVarName, null) + logPropertiesWriter.IfBlock(tmpVarName + " != null", body => + OutputState(body, stateVarName, logPropertyName.Wrap(), tmpVarName, null) ); logPropertiesWriter.Write("}"); } @@ -640,48 +648,84 @@ LoggerOutputContext output CodeWriter snippet = new(GenerationSettings.Create(), throwOnUnclosedScopes: false); var iteratorVarName = FindUniqueName("tmp_i", existingParamNames); var iteratorItemVarName = FindUniqueName("item", existingParamNames); - snippet.IfBlock( - parameter.Name + " != null", - body => + snippet.IfBlock(parameter.Name + " != null", body => + { + body.Write("var ").Write(iteratorVarName).Line(" = 0;"); + + var maxCount = parameter.ExpandEnumerableAttribute!.Value.MaximumValueCount; + + if (maxCount < 1) + maxCount = 1; + + if (maxCount > PropertyLibrary.Logging.UnboundedIEnumerableMaxCountBeforeDiagnostic) + { + output.Context.Diagnostic($"Identified {parameter.Name} that has a large unbounded ienumerable max."); + } + + body.Foreach("var " + iteratorItemVarName + " in " + parameter.Name, loopBody => { - body.Write("var ").Write(iteratorVarName).Line(" = 0;"); + loopBody.IfBlock( + iteratorVarName + " == " + maxCount.ToString(CultureInfo.InvariantCulture), + static breakBody => breakBody.Line("break;") + ); + + loopBody.NewLine(); + + OutputState( + loopBody, + stateVarName, + $"$\"{parameter.Name}[{{{iteratorVarName}}}]\"", + iteratorItemVarName, + null + ); - var maxCount = parameter.ExpandEnumerableAttribute!.Value.MaximumValueCount; + loopBody.Write(iteratorVarName).Line("++;"); + }); + }); - if (maxCount < 1) - maxCount = 1; + return snippet.ToString().TrimEnd('\n'); + } - if (maxCount > PropertyLibrary.Logging.UnboundedIEnumerableMaxCountBeforeDiagnostic) + /// + /// Emits the [TagProvider] call for one parameter: the provider's pairs are added to the log state as extra + /// tags once the fixed-size state entries are written, because the number of pairs is only known at run time. + /// + static string OutputTagProvider( + string stateVarName, + LogParameterTarget parameter, + TagProviderTarget tagProvider, + List existingParamNames + ) + { + CodeWriter snippet = new(GenerationSettings.Create(), throwOnUnclosedScopes: false); + var tagVariableName = FindUniqueName("tag", existingParamNames); + + void EmitPairs(CodeWriter target) + { + target.Foreach( + $"var {tagVariableName} in {tagProvider.ProviderTypeName}.{tagProvider.MethodName}({parameter.RenderValueExpression()})", + loopBody => { - output.Context.Diagnostic( - $"Identified {parameter.Name} that has a large unbounded ienumerable max." - ); + // LoggerMessageState.AddTag takes (string, object?), so a string-valued pair boxes; nothing to do. + loopBody + .Write(stateVarName) + .Write(".AddTag(") + .Write( + tagProvider.OmitReferenceName + ? tagVariableName + ".Key" + : $"\"{parameter.Name}.\" + {tagVariableName}.Key" + ) + .Write(", ") + .Write(tagVariableName) + .Line(".Value);"); } + ); + } - body.Foreach( - "var " + iteratorItemVarName + " in " + parameter.Name, - loopBody => - { - loopBody.IfBlock( - iteratorVarName + " == " + maxCount.ToString(CultureInfo.InvariantCulture), - static breakBody => breakBody.Line("break;") - ); - - loopBody.NewLine(); - - OutputState( - loopBody, - stateVarName, - $"$\"{parameter.Name}[{{{iteratorVarName}}}]\"", - iteratorItemVarName, - null - ); - - loopBody.Write(iteratorVarName).Line("++;"); - } - ); - } - ); + if (parameter.ParameterType.IsNullable) + snippet.IfBlock(parameter.Name + " != null", EmitPairs); + else + EmitPairs(snippet); return snippet.ToString().TrimEnd('\n'); } @@ -766,12 +810,20 @@ List existingParamNames static bool IsSimpleLogMethod(LogMethodTarget methodTarget) { + // A nested state struct cannot re-declare the method's type parameters, so a generic method always uses the + // thread-local state bag instead of the typed struct. + if (methodTarget.IsGeneric) + return false; + foreach (var param in methodTarget.Parameters) { if (param.LogPropertiesAttribute != null) return false; if (param.ExpandEnumerableAttribute != null) return false; + // A tag provider contributes a run-time number of tags, which a fixed-size typed state struct cannot hold. + if (param.TagProvider != null) + return false; } return true; @@ -1245,19 +1297,17 @@ [.. methodTarget.Parameters] IsOverride = true, }, body => - body.HashDefines( - "NET", - hashWriter => - hashWriter - .Write( - "return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $" - ) - .Write(interpolatedMessage.Wrap()) - .Line(");") - .HashElse() - .Write("return global::System.FormattableString.Invariant($") - .Write(interpolatedMessage.Wrap()) - .Line(");") + body.HashDefines("NET", hashWriter => + hashWriter + .Write( + "return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $" + ) + .Write(interpolatedMessage.Wrap()) + .Line(");") + .HashElse() + .Write("return global::System.FormattableString.Invariant($") + .Write(interpolatedMessage.Wrap()) + .Line(");") ) ); @@ -1369,6 +1419,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) diff --git a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs index 25a341c3..c8dc944e 100644 --- a/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/LoggerGenTargetClassEmitter.cs @@ -35,7 +35,8 @@ public static void GenerateImplementation(LoggerOutputContext output, SourceProd target.GenerationType, target.ClassNameToGenerate, target.InterfaceType, - TypeDeclarationAccessibility.Internal + TypeDeclarationAccessibility.Internal, + target.InterfaceTypeParameters ) ) { diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs index 9c9e3d45..013b5f8e 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.Methods.cs @@ -24,6 +24,7 @@ internal static void EmitThrowStub(CodeWriter writer, LogMethodTarget methodTarg p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), ExpressionBody = "throw new global::System.NotSupportedException()", } ) @@ -128,6 +129,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) @@ -142,10 +144,13 @@ SourceProductionContext context } else { - writer.IfBlock( - "!" + PropertyLibrary.Logging.LoggerFieldName + ".IsEnabled(" + methodTarget.MSLevel + ")", - static body => body.Return() - ); + // [Log(SkipEnabledCheck = true)]: the caller has already checked the level, so the per-call virtual + // IsEnabled dispatch is redundant. Same contract as [LoggerMessage(SkipEnabledCheck = true)]. + if (!methodTarget.SkipEnabledCheck) + writer.IfBlock( + "!" + PropertyLibrary.Logging.LoggerFieldName + ".IsEnabled(" + methodTarget.MSLevel + ")", + static body => body.Return() + ); writer .NewLine() @@ -207,6 +212,7 @@ SourceProductionContext context p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) diff --git a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs index ae22ff10..cc105b53 100644 --- a/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/LoggerTargetClassEmitter.cs @@ -33,7 +33,8 @@ public static void GenerateImplementation(LoggerOutputContext output, SourceProd target.GenerationType, target.ClassNameToGenerate, target.InterfaceType, - TypeDeclarationAccessibility.Internal + TypeDeclarationAccessibility.Internal, + target.InterfaceTypeParameters ) ) { diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs index 857e8cdc..62d425bc 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs @@ -37,12 +37,8 @@ static void EmitInitializationMethod(MeterOutputContext output, CodeWriter write { // Double-init guard: prevents re-initialization when the method path is used // (occurs in Logging+Metrics multi-target where Logging owns the constructor). - writer.IfBlock( - MeterFieldName + " != null", - body => - body.Throw( - "new " + TypeLibrary.System.Exception + "(\"The meters have already been initialized.\")" - ) + writer.IfBlock(MeterFieldName + " != null", body => + body.Throw("new " + TypeLibrary.System.Exception + "(\"The meters have already been initialized.\")") ); writer.NewLine(); @@ -102,6 +98,10 @@ static void EmitInitializationBodyContent(MeterOutputContext output, CodeWriter var supportsIMeterFactory = output.Context.Capabilities.SupportsIMeterFactory; const string meterTagsVariableName = "meterTags"; + // An explicit [Meter]/[MeterGeneration] Version wins; otherwise the assembly's own version, resolved once per + // compilation on the capabilities rather than read per target here. + var meterVersion = target.MeterVersion ?? output.Context.Capabilities.AssemblyVersion; + var dictType = GetDictionaryType(writer); writer.Assignment((string)dictType, meterTagsVariableName, "new " + (string)dictType + "()").NewLine(); @@ -109,6 +109,23 @@ static void EmitInitializationBodyContent(MeterOutputContext output, CodeWriter if (supportsIMeterFactory) { + // Attribute-supplied tags first (key=value from [Meter]/[MeterGeneration]), then the consumer's + // partial hook, so a consumer can override any key at runtime. + foreach (var tag in target.MeterTags) + { + var separator = tag.IndexOf('='); + if (separator <= 0) + continue; + + writer + .Write(meterTagsVariableName) + .Write('[') + .Write(tag.Substring(0, separator).Wrap()) + .Write("] = ") + .Write(tag.Substring(separator + 1).Wrap()) + .Line(";"); + } + writer .Write(MeterFieldName) .Write(" = ") @@ -120,7 +137,9 @@ static void EmitInitializationBodyContent(MeterOutputContext output, CodeWriter .Write(") {") .NewLine(); writer.Indent(); - writer.Line("Version = null,"); + writer.Line( + "Version = " + (meterVersion is null ? PropertyLibrary.System.NullKeyword : meterVersion.Wrap()) + "," + ); writer.Line($"Tags = {meterTagsVariableName}"); writer.Unindent(); writer.Write("});").NewLine(); @@ -132,9 +151,13 @@ static void EmitInitializationBodyContent(MeterOutputContext output, CodeWriter .Write(" = new ") .Write(TypeLibrary.System.Diagnostics.Metrics.Meter) .Write('(') - .Write(target.MeterName!.Wrap()) - .Line(");") - .NewLine(); + .Write(target.MeterName!.Wrap()); + + // Meter(string name, string? version) is the only metadata the constructor carries. + if (meterVersion is not null) + writer.Write(", ").Write(meterVersion.Wrap()); + + writer.Line(");").NewLine(); } foreach (var method in target.InstrumentationMethods) diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs index 9d63e8a1..e4c3efc9 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.Methods.cs @@ -26,6 +26,7 @@ static void EmitThrowStub(CodeWriter writer, InstrumentTarget methodTarget) p.ParameterType )), ], + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), ExpressionBody = "throw new global::System.NotSupportedException()", } ) @@ -163,6 +164,7 @@ SourceProductionContext context { Attributes = [EmitterHelpers.AggressiveInliningAttribute()], Parameters = parameters, + GenericTypes = TypeParameterHelpers.ToOptions(methodTarget.TypeParameters), } ) ) @@ -232,30 +234,27 @@ .. methodTarget.Parameters.Select(p => static void EmitObservableInstrumentBodyTest(CodeWriter writer, InstrumentTarget method) { - writer.IfBlock( - method.FieldName + " != null", - body => + writer.IfBlock(method.FieldName + " != null", body => + { + if (method.InstrumentAttribute?.ThrowOnAlreadyInitialized == true) { - if (method.InstrumentAttribute?.ThrowOnAlreadyInitialized == true) - { - writer - .Write("throw new ") - .Write(TypeLibrary.System.Exception) - .Write("(\"") - .Write(method.MetricName) - .Line(" has already been initialized.\");"); - } - else - { - writer.Write("return"); + writer + .Write("throw new ") + .Write(TypeLibrary.System.Exception) + .Write("(\"") + .Write(method.MetricName) + .Line(" has already been initialized.\");"); + } + else + { + writer.Write("return"); - if (method.ReturnsBool) - writer.Line(" false;"); - else - writer.Write(";").NewLine(); - } + if (method.ReturnsBool) + writer.Line(" false;"); + else + writer.Write(";").NewLine(); } - ); + }); writer.NewLine(); } @@ -382,15 +381,13 @@ static void EmitInstrumentBody( { if (param.SkipOnNullOrEmpty) { - writer.IfBlock( - param.ParameterName + " != default", - body => - body.MethodCallOn( - tagVariableName, - "Add", - param.GeneratedName.Wrap(), - param.RenderTagValueExpression() - ) + writer.IfBlock(param.ParameterName + " != default", body => + body.MethodCallOn( + tagVariableName, + "Add", + param.GeneratedName.Wrap(), + param.RenderTagValueExpression() + ) ); } else diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs index 624f4b79..7c9b2132 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.cs @@ -43,7 +43,8 @@ public static void GenerateImplementation(MeterOutputContext output, SourceProdu target.GenerationType, target.ClassNameToGenerate, target.InterfaceType, - TypeDeclarationAccessibility.Internal + TypeDeclarationAccessibility.Internal, + target.InterfaceTypeParameters ) ) { diff --git a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs index 3e201cf2..f4d7ea9a 100644 --- a/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs +++ b/src/src/SourceGenerator/Emitters/UnionAccessorEmitter.cs @@ -18,7 +18,7 @@ static class UnionAccessorEmitter public static void EmitAccessors(CodeWriter writer, IEnumerable unions) { List distinct = []; - HashSet emitted = new(StringComparer.Ordinal); + HashSet emitted = [with(StringComparer.Ordinal)]; foreach (var union in unions) { @@ -64,9 +64,8 @@ static void EmitAccessor(CodeWriter writer, UnionTargetRecord union) // class union. Both mean "no value", which unwraps to null. if (union.IsValueType) { - writer.IfBlock( - "!value." + UnionHelpers.HasValueMemberName, - static body => body.Return(PropertyLibrary.System.NullKeyword) + writer.IfBlock("!value." + UnionHelpers.HasValueMemberName, static body => + body.Return(PropertyLibrary.System.NullKeyword) ); writer @@ -93,30 +92,27 @@ static void EmitAccessor(CodeWriter writer, UnionTargetRecord union) return; } - writer.IfBlock( - value + "." + UnionHelpers.HasValueMemberName, - body => + writer.IfBlock(value + "." + UnionHelpers.HasValueMemberName, body => + { + for (var i = 0; i < union.CaseTypes.Count; i++) { - for (var i = 0; i < union.CaseTypes.Count; i++) - { - var caseVariableName = "caseValue" + i.ToString(CultureInfo.InvariantCulture); - - // The out argument is the case type itself rather than `var`: a union declares one - // TryGetValue overload per case, so an inferred type would be ambiguous. - body.IfBlock( - value - + "." - + UnionHelpers.TryGetValueMemberName - + "(out " - + union.CaseTypes[i].RenderFullNameForNullable(useNullable) - + " " - + caseVariableName - + ")", - caseBody => caseBody.Return(caseVariableName) - ); - } + var caseVariableName = "caseValue" + i.ToString(CultureInfo.InvariantCulture); + + // The out argument is the case type itself rather than `var`: a union declares one + // TryGetValue overload per case, so an inferred type would be ambiguous. + body.IfBlock( + value + + "." + + UnionHelpers.TryGetValueMemberName + + "(out " + + union.CaseTypes[i].RenderFullNameForNullable(useNullable) + + " " + + caseVariableName + + ")", + caseBody => caseBody.Return(caseVariableName) + ); } - ); + }); writer.NewLine().Return(PropertyLibrary.System.NullKeyword); } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Activities.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Activities.cs index 755e915c..e2ac45f2 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Activities.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Activities.cs @@ -18,22 +18,19 @@ IncrementalValueProvider> generationCon .Select(static (pair, _) => new ActivityOutputContext(pair.Left.Value!, pair.Right)) .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_ActivityOutputs"); - context.RegisterSourceOutput( - source: outputContexts, - action: static (spc, output) => + context.RegisterSourceOutput(source: outputContexts, action: static (spc, output) => + { + if (output.Context.Settings.IsSourceGeneratorDisabled) { - if (output.Context.Settings.IsSourceGeneratorDisabled) - { - output.Context.Debug( - $"Activity generation skipped for {output.Target.FullyQualifiedName} because the generator is disabled" - ); - return; - } + output.Context.Debug( + $"Activity generation skipped for {output.Target.FullyQualifiedName} because the generator is disabled" + ); + return; + } - output.Context.Debug($"Activity generation target: {output.Target.FullyQualifiedName}"); + output.Context.Debug($"Activity generation target: {output.Target.FullyQualifiedName}"); - RunSafely(spc, () => ActivitySourceTargetClassEmitter.GenerateImplementation(output, spc)); - } - ); + RunSafely(spc, () => ActivitySourceTargetClassEmitter.GenerateImplementation(output, spc)); + }); } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Logging.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Logging.cs index 94ef3add..a9d358ae 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Logging.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Logging.cs @@ -29,25 +29,22 @@ IncrementalValueProvider> generationCon .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_LoggerOutputs"); // Register with the source generator. - context.RegisterSourceOutput( - source: outputContexts, - action: static (spc, output) => + context.RegisterSourceOutput(source: outputContexts, action: static (spc, output) => + { + if (output.Context.Settings.IsSourceGeneratorDisabled) { - if (output.Context.Settings.IsSourceGeneratorDisabled) - { - output.Context.Debug( - $"Logger generation skipped for {output.Target.FullyQualifiedName} because the generator is disabled" - ); - return; - } + output.Context.Debug( + $"Logger generation skipped for {output.Target.FullyQualifiedName} because the generator is disabled" + ); + return; + } - output.Context.Debug($"Logger generation target: {output.Target.FullyQualifiedName}"); + output.Context.Debug($"Logger generation target: {output.Target.FullyQualifiedName}"); - if (output.Target.UseMSLoggingTelemetryBasedGeneration) - RunSafely(spc, () => LoggerGenTargetClassEmitter.GenerateImplementation(output, spc)); - else - RunSafely(spc, () => LoggerTargetClassEmitter.GenerateImplementation(output, spc)); - } - ); + if (output.Target.UseMSLoggingTelemetryBasedGeneration) + RunSafely(spc, () => LoggerGenTargetClassEmitter.GenerateImplementation(output, spc)); + else + RunSafely(spc, () => LoggerTargetClassEmitter.GenerateImplementation(output, spc)); + }); } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Metrics.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Metrics.cs index 9182e5b1..a5701662 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Metrics.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.Metrics.cs @@ -18,21 +18,18 @@ IncrementalValueProvider> generationCon .Select(static (pair, _) => new MeterOutputContext(pair.Left.Value!, pair.Right)) .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_MeterOutputs"); - context.RegisterSourceOutput( - source: outputContexts, - action: static (spc, output) => + context.RegisterSourceOutput(source: outputContexts, action: static (spc, output) => + { + if (output.Context.Settings.IsSourceGeneratorDisabled) { - if (output.Context.Settings.IsSourceGeneratorDisabled) - { - output.Context.Debug( - $"Meter generation skipped for {output.Target.FullyQualifiedName} because the generator is disabled" - ); - return; - } - - output.Context.Debug($"Meter generation target: {output.Target.FullyQualifiedName}"); - RunSafely(spc, () => MeterTargetClassEmitter.GenerateImplementation(output, spc)); + output.Context.Debug( + $"Meter generation skipped for {output.Target.FullyQualifiedName} because the generator is disabled" + ); + return; } - ); + + output.Context.Debug($"Meter generation target: {output.Target.FullyQualifiedName}"); + RunSafely(spc, () => MeterTargetClassEmitter.GenerateImplementation(output, spc)); + }); } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs index 9729ed59..8cab0fe8 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs @@ -56,9 +56,8 @@ IncrementalValueProvider> generationCon ) .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_TelemetryNamesAttributeOutput"); - context.RegisterSourceOutput( - source: attributeOutput, - action: static (spc, output) => GenerateTelemetryNamesAttribute(output, spc) + context.RegisterSourceOutput(source: attributeOutput, action: static (spc, output) => + GenerateTelemetryNamesAttribute(output, spc) ); // The names referenced assemblies recorded, projected to strings before they reach a cached node. @@ -77,9 +76,8 @@ IncrementalValueProvider> generationCon ) .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_TelemetryNamesOutput"); - context.RegisterSourceOutput( - source: classOutput, - action: static (spc, output) => GenerateTelemetryNames(output, spc) + context.RegisterSourceOutput(source: classOutput, action: static (spc, output) => + GenerateTelemetryNames(output, spc) ); } @@ -226,8 +224,8 @@ static ReferencedTelemetryNamesContext CollectReferencedNames(Compilation compil { token.ThrowIfCancellationRequested(); - SortedSet activitySourceNames = new(StringComparer.Ordinal); - SortedSet meterNames = new(StringComparer.Ordinal); + SortedSet activitySourceNames = [with(StringComparer.Ordinal)]; + SortedSet meterNames = [with(StringComparer.Ordinal)]; ReferencedTelemetryNames.Collect(compilation, activitySourceNames, meterNames, token); @@ -266,15 +264,8 @@ static void GenerateTelemetryNames(TelemetryNamesOutputContext output, SourcePro // Use custom class name if provided, otherwise default to "TelemetryNames" var className = string.IsNullOrWhiteSpace(output.Names.ClassName) ? "TelemetryNames" : output.Names.ClassName!; - RunSafely( - spc, - () => - TelemetryNamesEmitter.GenerateClass( - output, - className, - output.Names.Namespace ?? output.AssemblyName, - spc - ) + RunSafely(spc, () => + TelemetryNamesEmitter.GenerateClass(output, className, output.Names.Namespace ?? output.AssemblyName, spc) ); } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs index 2f84ce67..050a38b4 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs @@ -88,20 +88,25 @@ CancellationToken token { token.ThrowIfCancellationRequested(); - // C# 8+ feature detection: controls nullable annotations and null-forgiving operators. - // IMeterFactory is .NET 8+ only — never available on .NET Framework 4.8. - var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions; + // Capabilities are probed on the compilation, never on the target framework: the + // System.Diagnostics.DiagnosticSource and Microsoft.Extensions.Diagnostics.Abstractions packages bring these + // types to net462 and netstandard2.0 consumers, and a net10.0 consumer that pins an older line may not have + // them either. IMeterFactory in particular is not ".NET 8+" in practice — it is whatever the referenced + // package provides, so a symbol probe is the only correct gate. return new( //SupportsNullableAnnotations: parseOptions is null //|| parseOptions.LanguageVersion >= LanguageVersion.CSharp8, - SupportsIMeterFactory: parseOptions is null - || !parseOptions.PreprocessorSymbolNames.Contains("NET48_OR_GREATER"), + SupportsIMeterFactory: SharedHelpers.IsMeterFactoryAvailable(compilation), UseActivitySourceFactory: SharedHelpers.UseActivitySourceFactory(compilation, token), SupportsGauge: SharedHelpers.IsTypeAvailable(compilation, TypeLibrary.System.Diagnostics.Metrics.Gauge), SupportsInstrumentAdvice: SharedHelpers.IsTypeAvailable( compilation, TypeLibrary.System.Diagnostics.Metrics.InstrumentAdvice - ) + ), + SupportsActivityAddException: SharedHelpers.IsActivityAddExceptionAvailable(compilation), + // Resolved once per compilation and carried on the capabilities, so every meter and activity source in + // the project reuses one value instead of reading the assembly identity per target. + AssemblyVersion: SharedHelpers.GetAssemblyVersion(compilation) ); } } diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs index ca07780a..c1e09542 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs @@ -65,8 +65,8 @@ public static class General public static readonly ReportableDiagnostic GenericInterfacesNotSupported = ReportableDiagnostic.Create( new DiagnosticDescriptor( id: "TSG1004", - title: "Generic interfaces are not supported", - messageFormat: "Remove the generic type(s) from the interface, this type of generation is not supported.", + title: "Generic interface cannot be generated", + messageFormat: "The generic interface '{0}' cannot be generated: only a top-level generic interface whose type parameters the generated class can mirror is supported. A nested generic interface, or one whose type parameter carries the 'allows ref struct' anti-constraint, is not.", defaultSeverity: DiagnosticSeverity.Error, category: Categories.Usage, isEnabledByDefault: true @@ -77,8 +77,8 @@ public static class General public static readonly ReportableDiagnostic GenericMethodsNotSupported = ReportableDiagnostic.Create( new DiagnosticDescriptor( id: "TSG1005", - title: "Generic methods are not supported", - messageFormat: "Remove the generic type(s) from the method, this type of generation is not supported.", + title: "Generic method cannot be generated", + messageFormat: "The generic method '{0}' cannot be generated: {1}. Generic methods and constraints are mirrored where C# allows it; the method is emitted as a throwing stub so the interface stays implemented.", defaultSeverity: DiagnosticSeverity.Error, category: Categories.Usage, isEnabledByDefault: true diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Logging.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Logging.cs index 8ee2469e..065851c0 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Logging.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Logging.cs @@ -128,5 +128,77 @@ public static class Logging ), isBlocking: false ); + + public static readonly ReportableDiagnostic ParamsParameterNotSupported = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG2022", + title: "params parameters are not supported", + messageFormat: "A logging parameter cannot be declared 'params'. The generated method re-declares the parameter without the modifier, so the params call form would stop compiling; take an array and mark it [ExpandEnumerable] instead.", + defaultSeverity: DiagnosticSeverity.Error, + category: Categories.Logging.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic ByRefParameterNotSupported = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG2023", + title: "ref, out and ref struct parameters are not supported", + messageFormat: "A logging parameter cannot be 'ref', 'out', 'in', 'scoped', or a ref struct such as Span. The generated state struct cannot capture it, and LoggerMessage.Define cannot carry it.", + defaultSeverity: DiagnosticSeverity.Error, + category: Categories.Logging.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic TagProviderTypeNotFound = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG2024", + title: "TagProvider type could not be resolved", + messageFormat: "The [TagProvider] on parameter '{0}' names the type '{1}', which cannot be resolved. The parameter is logged without the extra tags.", + defaultSeverity: DiagnosticSeverity.Error, + category: Categories.Logging.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic TagProviderMethodNotFound = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG2025", + title: "TagProvider method could not be found", + messageFormat: "The [TagProvider] on parameter '{0}' names the method '{1}' on '{2}', which does not exist. The parameter is logged without the extra tags.", + defaultSeverity: DiagnosticSeverity.Error, + category: Categories.Logging.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic TagProviderMethodInvalidSignature = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG2026", + title: "TagProvider method has an invalid signature", + messageFormat: "The [TagProvider] method '{0}' on '{1}' must be a visible static method taking one '{2}' parameter and returning IEnumerable> or IEnumerable>. The parameter is logged without the extra tags.", + defaultSeverity: DiagnosticSeverity.Error, + category: Categories.Logging.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + + public static readonly ReportableDiagnostic TagProviderNotOnLoggingMethod = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG2027", + title: "TagProvider is only supported on logging parameters", + messageFormat: "The [TagProvider] on parameter '{0}' is on a method that generates no log entry, so the provider would never run. Move it to a logging method, or remove it.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Logging.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); } } diff --git a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs index 7d4a97f9..b03a70ca 100644 --- a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs +++ b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs @@ -37,9 +37,6 @@ CancellationToken token if (TypeHelpers.HasAttribute(method, TypeLibrary.Purview.Telemetry.ExcludeAttribute)) continue; - if (method.Arity > 0) - continue; - var instrumentAttribute = SharedHelpers.GetInstrumentAttribute(method, token); var validAutoCounter = instrumentAttribute is CounterInstrumentAttributeRecord { AutoIncrement: true }; @@ -48,7 +45,8 @@ CancellationToken token lowercaseTagKeys, validAutoCounter, namingConvention, - token + token, + out var measurementTypeSymbol ); var measurementParameters = parameters .Where(m => m.ParamDestination == InstrumentParameterDestination.Measurement) @@ -81,6 +79,21 @@ CancellationToken token var instrumentMeasurementType = measurementParameter?.InstrumentType ?? TypeLibrary.System.Int32; + // TSG1005: a constraint the generator cannot re-declare. A generic *measurement* is only usable when its + // type parameter is declared by the interface, because the instrument is a field: a method-level type + // parameter cannot appear in a field's type. + var typeParameters = TypeParameterHelpers.FromMethod(method); + if ( + !TypeParameterHelpers.AreSupported(method.TypeParameters) + || ( + measurementTypeSymbol is ITypeParameterSymbol measurementTypeParameter + && method.TypeParameters.Contains(measurementTypeParameter, SymbolEqualityComparer.Default) + ) + ) + { + targetGenerationState = targetGenerationState with { IsValid = false }; + } + methodTargets.Add( new( MethodName: method.Name, @@ -95,13 +108,14 @@ CancellationToken token Parameters: parameters, Tags: tagParameters, MeasurementParameter: measurementParameter, - TargetGenerationState: targetGenerationState + TargetGenerationState: targetGenerationState, + TypeParameters: typeParameters ) ); } // Post-pass: mark duplicate method names as invalid (emitter generates throw stubs; TSG1003 raised by analyzer) - HashSet seenNames = new(StringComparer.Ordinal); + HashSet seenNames = [with(StringComparer.Ordinal)]; for (var i = 0; i < methodTargets.Count; i++) { var t = methodTargets[i]; @@ -163,16 +177,19 @@ static ImmutableArray GetInstrumentParameters( bool lowercaseTagKeys, bool isAutoCounter, int namingConvention, - CancellationToken token + CancellationToken token, + out ITypeSymbol? measurementTypeSymbol ) { + measurementTypeSymbol = null; + List parameterTargets = []; foreach (var parameter in method.Parameters) { token.ThrowIfCancellationRequested(); // Skip Activity-related parameters - they are not valid for metrics - var paramType = TypeReference.Create(parameter.Type); + var paramType = SharedHelpers.ToTypeReference(parameter.Type); if ( paramType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity) || paramType.Identity.Equals(TypeLibrary.System.Diagnostics.ActivityContext) @@ -196,7 +213,10 @@ CancellationToken token (isFuncType, isIEnumerableType, isMeasurementType, isValidInstrumentType, instrumentType) = TryResolveMeasurementType(parameter.Type, isAutoCounter); if (instrumentType != null) + { destination = InstrumentParameterDestination.Measurement; + measurementTypeSymbol ??= ResolvedMeasurementType(parameter.Type, isAutoCounter); + } } if (destination == InstrumentParameterDestination.Unknown) @@ -216,7 +236,7 @@ CancellationToken token parameterTargets.Add( new( ParameterName: parameterName, - ParameterType: TypeReference.Create(parameter.Type), + ParameterType: SharedHelpers.ToTypeReference(parameter.Type), IsFunc: isFuncType, IsIEnumerable: isIEnumerableType, IsMeasurement: isMeasurementType, @@ -269,6 +289,16 @@ out var attribute TypeReference? InstrumentType ) TryResolveMeasurementType(ITypeSymbol parameterType, bool isAutoCounter) { + // A type-parameter measurement: the instrument types all declare `where T : struct`, so only a + // value-type-constrained parameter can be measured. Whether the parameter is declared by the method or the + // interface decides whether the instrument field can name it; the caller checks that. + if (parameterType is ITypeParameterSymbol typeParameter) + { + return typeParameter.HasValueTypeConstraint && !isAutoCounter + ? (false, false, false, true, SharedHelpers.ToTypeReference(parameterType)) + : (false, false, false, false, null); + } + if (parameterType is not INamedTypeSymbol namedParameterType) return (false, false, false, false, null); @@ -276,7 +306,7 @@ out var attribute if (!isFunc) { // For non-observable instruments. - var isValid = SharedHelpers.IsValidMeasurementValueType(namedParameterType); + var isValid = IsUsableMeasurementType(namedParameterType); if (isValid && !isAutoCounter) { var typeRef = TypeReference.Create(namedParameterType); @@ -289,6 +319,13 @@ out var attribute // For observable instruments: Func<...>. var funcArg = namedParameterType.TypeArguments[0]; + if (funcArg is ITypeParameterSymbol genericMeasurement) + { + return genericMeasurement.HasValueTypeConstraint + ? (true, false, false, true, SharedHelpers.ToTypeReference(genericMeasurement)) + : (true, false, false, false, null); + } + if ( funcArg is INamedTypeSymbol enumerableType && TypeLibrary.System.Collections.Generic.IEnumerable.Equals(enumerableType.ConstructedFrom) @@ -303,10 +340,10 @@ enumerableArg is INamedTypeSymbol measurementContainer { // Func>> var valueType = measurementContainer.TypeArguments[0]; - var isValid = SharedHelpers.IsValidMeasurementValueType(valueType); + var isValid = IsUsableMeasurementType(valueType); if (isValid) { - var typeRef = TypeReference.Create(valueType); + var typeRef = SharedHelpers.ToTypeReference(valueType); return (true, true, true, true, typeRef); } @@ -322,10 +359,10 @@ funcArg is INamedTypeSymbol funcMeasurementType ) { // Func> - var isValid = SharedHelpers.IsValidMeasurementValueType(funcMeasurementType.TypeArguments[0]); + var isValid = IsUsableMeasurementType(funcMeasurementType.TypeArguments[0]); if (isValid) { - var typeRef = TypeReference.Create(funcMeasurementType.TypeArguments[0]); + var typeRef = SharedHelpers.ToTypeReference(funcMeasurementType.TypeArguments[0]); return (true, false, true, true, typeRef); } @@ -333,16 +370,55 @@ funcArg is INamedTypeSymbol funcMeasurementType } // Func directly. - var isValidDirect = SharedHelpers.IsValidMeasurementValueType(funcArg); + var isValidDirect = IsUsableMeasurementType(funcArg); if (isValidDirect) { - var directRef = TypeReference.Create(funcArg); + var directRef = SharedHelpers.ToTypeReference(funcArg); return (true, false, false, true, directRef); } return (true, false, false, false, null); } + /// + /// Whether the type can be an instrument measurement: one of the numeric types the metrics API accepts, or a + /// type parameter the instrument's own where T : struct constraint accepts. + /// + static bool IsUsableMeasurementType(ITypeSymbol type) => + type is ITypeParameterSymbol { HasValueTypeConstraint: true } + || (type is INamedTypeSymbol named && SharedHelpers.IsValidMeasurementValueType(named)); + + /// + /// The symbol whose type is the instrument's measurement, unwrapping Func<T>, + /// Func<Measurement<T>> and Func<IEnumerable<Measurement<T>>>. + /// + static ITypeSymbol? ResolvedMeasurementType(ITypeSymbol parameterType, bool isAutoCounter) + { + _ = isAutoCounter; + + if (parameterType is not INamedTypeSymbol named || !new TypeIdentity(typeof(Func<>)).Matches(named)) + return parameterType; + + var funcArg = named.TypeArguments[0]; + + if (funcArg is INamedTypeSymbol { Arity: 1 } measurement) + { + if (TypeLibrary.System.Diagnostics.Metrics.Measurement.Equals(measurement.ConstructedFrom)) + return measurement.TypeArguments[0]; + + if ( + TypeLibrary.System.Collections.Generic.IEnumerable.Equals(measurement.ConstructedFrom) + && measurement.TypeArguments[0] is INamedTypeSymbol { Arity: 1 } inner + && TypeLibrary.System.Diagnostics.Metrics.Measurement.Equals(inner.ConstructedFrom) + ) + { + return inner.TypeArguments[0]; + } + } + + return funcArg; + } + static string? GeneratePrefix( MeterGenerationAttributeData? meterGenerationAttribute, MeterAttributeData meterAttribute, @@ -353,8 +429,8 @@ CancellationToken token token.ThrowIfCancellationRequested(); string? prefix = null; - var separator = - meterGenerationAttribute?.InstrumentSeparator ?? PropertyLibrary.Metrics.InstrumentSeparatorDefault; + var separator = meterGenerationAttribute?.InstrumentSeparator + ?? PropertyLibrary.Metrics.InstrumentSeparatorDefault; if ( meterAttribute.IncludeAssemblyInstrumentPrefix diff --git a/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs b/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs index 7db15c40..5be704a5 100644 --- a/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs +++ b/src/src/SourceGenerator/Helpers/LogMethodModelBuilder.cs @@ -44,11 +44,6 @@ CancellationToken token continue; } - if (method.Arity > 0) - { - continue; - } - // Validate return type - don't skip; let through with UnknownReturnType flag so the emitter can report the diagnostic var invalidReturnType = TelemetryRules.IsInvalidLogReturnType(method, token); @@ -103,6 +98,16 @@ CancellationToken token GenerationType.Logging ); + // TSG2022 / TSG2023: a 'params', by-ref ('ref'/'out'/'in'/'scoped') or ref struct parameter cannot be + // represented in the generated state, so the method is carried as invalid and the emitter writes a throw + // stub rather than code that fails to compile. The analyzer reports the diagnostic. + if ( + method.Parameters.Any(static parameter => + parameter.IsParams || parameter.RefKind != RefKind.None || parameter.Type.IsRefLikeType + ) + ) + targetGenerationState = targetGenerationState with { IsValid = false }; + // Resolve per-method generation mode. // Priority: method GenerationMode > interface/assembly GenerationMode > Auto (per-method param analysis). var useV1Generation = ResolveUseV1Generation( @@ -112,6 +117,17 @@ CancellationToken token methodParameters ); + // LoggerMessage.Define cannot carry an open generic, so a generic method is always state-based. + if (method.Arity > 0) + useV1Generation = false; + + // TSG1005: a type parameter whose constraint the generator cannot re-declare. The method is carried as + // invalid so the emitter writes a throw stub rather than an unimplemented interface member. + if (!TypeParameterHelpers.AreSupported(method.TypeParameters)) + targetGenerationState = targetGenerationState with { IsValid = false }; + + var typeParameters = TypeParameterHelpers.FromMethod(method); + methodTargets.Add( new( MethodName: method.Name, @@ -134,13 +150,15 @@ CancellationToken token InferredErrorLevel: inferredErrorLevel, TargetGenerationState: targetGenerationState, UseV1Generation: useV1Generation, - HasExplicitLevel: hasExplicitLevel + HasExplicitLevel: hasExplicitLevel, + SkipEnabledCheck: logAttribute?.SkipEnabledCheck ?? false, + TypeParameters: typeParameters ) ); } // Post-pass: mark duplicate method names as invalid (emitter generates throw stubs; TSG1003 raised by analyzer) - HashSet seenNames = new(StringComparer.Ordinal); + HashSet seenNames = [with(StringComparer.Ordinal)]; for (var i = 0; i < methodTargets.Count; i++) { var t = methodTargets[i]; @@ -171,8 +189,8 @@ ImmutableArray methodParameters ) { var logName = GetLogName(interfaceName, className, loggerTarget, logAttribute, methodName, defaultPrefixType); - var messageTemplate = - logAttribute?.MessageTemplate ?? GenerateTemplateMessage(logName, isScoped, methodParameters); + var messageTemplate = logAttribute?.MessageTemplate + ?? GenerateTemplateMessage(logName, isScoped, methodParameters); var hasMultipleExceptions = !isScoped && methodParameters.Count(m => m.IsException) > 1; var exceptionParam = hasMultipleExceptions ? null @@ -192,23 +210,20 @@ static LogMethodTarget BuildErrorStubTarget( int defaultLogLevel ) { - var stubParams = ImmutableArray.CreateRange( - method.Parameters, - p => new LogParameterTarget( - Name: p.Name, - UpperCasedName: Utilities.UppercaseFirstChar(p.Name), - ParameterType: TypeReference.Create(p.Type), - IsException: false, - IsFirstException: false, - IsIEnumerable: false, - IsArray: false, - IsComplexType: false, - LogPropertiesAttribute: null, - LogProperties: null, - ExpandEnumerableAttribute: null, - ExcludedTargets: GenerationType.None - ) - ); + var stubParams = ImmutableArray.CreateRange(method.Parameters, p => new LogParameterTarget( + Name: p.Name, + UpperCasedName: Utilities.UppercaseFirstChar(p.Name), + ParameterType: TypeReference.Create(p.Type), + IsException: false, + IsFirstException: false, + IsIEnumerable: false, + IsArray: false, + IsComplexType: false, + LogPropertiesAttribute: null, + LogProperties: null, + ExpandEnumerableAttribute: null, + ExcludedTargets: GenerationType.None + )); return new( MethodName: method.Name, @@ -233,7 +248,8 @@ int defaultLogLevel RaiseMultiGenerationTargetsNotSupported: false ), UseV1Generation: false, - HasLogPropertiesAndExpandEnumerable: true + HasLogPropertiesAndExpandEnumerable: true, + TypeParameters: TypeParameterHelpers.FromMethod(method) ); } @@ -295,6 +311,12 @@ static bool ResolveUseV1Generation( ImmutableArray methodParameters ) { + // LoggerMessage.Define carries a fixed parameter list and no tag bag, so a parameter whose tags are produced at + // run time cannot be logged through it. This wins over an explicit v1 request rather than silently dropping the + // provider's tags. + if (methodParameters.Any(p => p.TagProvider != null)) + return false; + if (methodGenMode == 1) // V1 forced at method level return true; if (methodGenMode == 2) // V2 forced at method level @@ -420,7 +442,7 @@ out bool hasError token.ThrowIfCancellationRequested(); // Skip Activity-related parameters and TagList - they are not valid for logging - var parameterType = TypeReference.Create(parameter.Type); + var parameterType = SharedHelpers.ToTypeReference(parameter.Type); if ( parameterType.Identity.Equals(TypeLibrary.System.Diagnostics.Activity) || parameterType.Identity.Equals(TypeLibrary.System.Diagnostics.ActivityContext) @@ -434,6 +456,7 @@ out bool hasError var logPropertiesAttribute = SharedHelpers.GetLogPropertiesAttribute(parameter, token); var expandEnumerableAttribute = SharedHelpers.GetExpandEnumerableAttribute(parameter, token); + var tagProviderAttribute = SharedHelpers.GetTagProviderAttribute(parameter, token); if (logPropertiesAttribute != null && expandEnumerableAttribute != null) { @@ -441,38 +464,28 @@ out bool hasError break; } + TagProviderTarget? tagProvider = null; + if (tagProviderAttribute != null) + { + var resolution = TagProviderResolver.Resolve(parameter, tagProviderAttribute.Value, compilation, token); + + // An unusable provider is reported by TSG2024/TSG2025/TSG2026; the parameter is still logged, just + // without the extra tags, so the method itself stays valid. + tagProvider = resolution.Target; + } + List? logProperties = null; if (logPropertiesAttribute != null) { // At this point, we know the caller wants to expand the properties for the given type. - // So we can find the names of all the properties and their types. + // So we can find the names of all the properties and their types - transitively, when asked. + var flattened = LogPropertiesFlattener.Flatten(parameter.Type, logPropertiesAttribute.Value, token); - var type = parameter.Type; - foreach (var property in type.GetMembers().OfType()) - { - var propertyName = property.Name; - if ( - Utilities.ContainsAttribute( - property, - TypeLibrary.Microsoft.Extensions.Logging.LogPropertyIgnoreAttribute, - token - ) - ) - { - continue; - } - - var isNullable = - property.Type.IsReferenceType - || property.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; - - logProperties ??= []; - - logProperties.Add(new(PropertyName: propertyName, IsNullable: isNullable)); - } + if (!flattened.IsEmpty) + logProperties = [.. flattened]; } - var logParameterType = TypeReference.Create(parameter.Type); + var logParameterType = SharedHelpers.ToTypeReference(parameter.Type); var isException = parameter.Type.IsExceptionType(); parameters.Add( new( @@ -489,7 +502,8 @@ out bool hasError ExpandEnumerableAttribute: expandEnumerableAttribute, ExcludedTargets: SharedHelpers.GetExcludeTargetsAttribute(parameter, token)?.ExcludedTargets ?? GenerationType.None, - Union: UnionHelpers.GetUnionTarget(parameter.Type, token) + Union: UnionHelpers.GetUnionTarget(parameter.Type, token), + TagProvider: tagProvider ) ); diff --git a/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs b/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs new file mode 100644 index 00000000..6b113c7d --- /dev/null +++ b/src/src/SourceGenerator/Helpers/LogPropertiesFlattener.cs @@ -0,0 +1,209 @@ +using Microsoft.CodeAnalysis; +using Purview.Telemetry.SourceGenerator.Records; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Flattens the readable properties of a [LogProperties] parameter into the tag entries the generated log +/// state carries. +/// +/// +/// Without Transitive the parameter's own properties are flattened, which is what the generator has always +/// done. With [LogProperties(Transitive = true)] each property whose type is itself a complex object is +/// flattened in turn, producing dotted tag names such as weather.Location.City. +/// +/// The walk mirrors Microsoft.Gen.Logging's Parser.LogProperties: it takes public instance properties +/// with a public getter, de-duplicates a property hidden by a derived type, walks base types, skips indexers and +/// statics, honours [LogPropertyIgnore] at every level, and recurses only into complex reference types and +/// non-nullable structs. matches that generator's +/// MaxTypeTraversalDepth. +/// +/// +/// Two deliberate divergences, both documented on docs/wiki/Logging.md: a type already on the visited chain +/// stops only that branch instead of abandoning the whole parameter, and a Nullable<T> property is +/// flattened no further because the extra .Value hop the accessor would need does not fit the generated +/// null-conditional chain. +/// +/// +static class LogPropertiesFlattener +{ + /// + /// The number of types, including the parameter's own, that may appear on one property chain. A chain that would + /// exceed it stops flattening, so a type graph deep enough to be rejected by the .NET 11 generator is not walked + /// here either. A self-referencing or mutually-referencing graph stops at the first repeated type instead. + /// + internal const int MaxTransitiveDepth = 32; + + /// + /// Known value-like types that are rendered with their own ToString rather than expanded. Expanding a + /// DateTime into Year/Month/... would replace a useful value with noise. + /// + static readonly HashSet StringifiableTypes = new(StringComparer.Ordinal) + { + "System.DateOnly", + "System.DateTime", + "System.DateTimeOffset", + "System.Guid", + "System.TimeOnly", + "System.TimeSpan", + "System.Uri", + "System.Version", + }; + + public static EquatableArray Flatten( + ITypeSymbol parameterType, + LogPropertiesAttributeData attribute, + CancellationToken token + ) + { + var flattenedType = UnwrapNullable(parameterType); + + List properties = []; + HashSet chain = new(SymbolEqualityComparer.Default) { flattenedType }; + + FlattenType( + flattenedType, + namePrefix: string.Empty, + accessorPrefix: string.Empty, + // The existing generator always writes `param?.Property`, which is only valid when the parameter can be + // null: a reference type or a Nullable. + receiverIsNullable: IsNullableType(parameterType), + transitive: attribute.Transitive, + chain, + properties, + token + ); + + return new([.. properties]); + } + + static void FlattenType( + ITypeSymbol type, + string namePrefix, + string accessorPrefix, + bool receiverIsNullable, + bool transitive, + HashSet chain, + List properties, + CancellationToken token + ) + { + // A property declared on a base type that a derived type hides is logged once, as the derived member. + HashSet seenPropertyNames = new(StringComparer.Ordinal); + + var current = type as INamedTypeSymbol; + while (current is not null && current.SpecialType != SpecialType.System_Object) + { + token.ThrowIfCancellationRequested(); + + foreach (var property in current.GetMembers().OfType()) + { + token.ThrowIfCancellationRequested(); + + if (property.IsIndexer || property.IsStatic) + continue; + + if (!seenPropertyNames.Add(property.Name)) + continue; + + if ( + property.DeclaredAccessibility != Accessibility.Public + || property.GetMethod is not { DeclaredAccessibility: Accessibility.Public } + ) + continue; + + if ( + Utilities.ContainsAttribute( + property, + TypeLibrary.Microsoft.Extensions.Logging.LogPropertyIgnoreAttribute, + token + ) + ) + { + continue; + } + + var propertyIsNullable = IsNullableType(property.Type); + var propertyName = namePrefix + property.Name; + var accessor = accessorPrefix + (receiverIsNullable ? "?." : ".") + property.Name; + + properties.Add(new(PropertyName: propertyName, IsNullable: propertyIsNullable, Accessor: accessor)); + + if (!transitive) + continue; + + var propertyType = UnwrapNullable(property.Type); + if (!IsTransitivelyExpandable(propertyType)) + continue; + + // A cycle stops this branch only: the parameters already flattened stay, and the repeated type's own + // properties are not walked again. + if (chain.Contains(propertyType)) + continue; + + if (chain.Count >= MaxTransitiveDepth) + continue; + + chain.Add(propertyType); + FlattenType( + propertyType, + propertyName + ".", + accessor, + propertyIsNullable, + transitive, + chain, + properties, + token + ); + chain.Remove(propertyType); + } + + current = current.BaseType; + } + } + + static ITypeSymbol UnwrapNullable(ITypeSymbol type) => + type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && type is INamedTypeSymbol named + ? named.TypeArguments[0] + : type; + + /// + /// Whether a value of this type can be null, and therefore whether the accessor chain needs a ?. A + /// non-nullable value type cannot take one, so a struct-typed parameter or property is accessed with a plain dot. + /// + static bool IsNullableType(ITypeSymbol type) => + type.IsReferenceType || type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + + /// + /// Whether the property's type is a complex object worth walking into. Primitives, enums, delegates, arrays, + /// collections, ref structs, type parameters and the well-known value-like BCL types stay single tags whose value + /// the logging provider renders with ToString. + /// + static bool IsTransitivelyExpandable(ITypeSymbol type) + { + if (type is not INamedTypeSymbol named) + return false; + + if (named.TypeKind is not (TypeKind.Class or TypeKind.Struct or TypeKind.Interface)) + return false; + + if (named.IsStatic || named.IsRefLikeType) + return false; + + if (named.SpecialType != SpecialType.None) + return false; + + if (named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + return false; + + if (StringifiableTypes.Contains(named.ToDisplayString())) + return false; + + // A collection is one tag: expanding List into Capacity/Count is not what a reader wants, and an array's + // elements are not expanded by the .NET generator either. + if (named.AllInterfaces.Any(static i => i.SpecialType == SpecialType.System_Collections_IEnumerable)) + return false; + + return true; + } +} diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs index ec96f6b6..bae1802a 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs @@ -25,13 +25,14 @@ CancellationToken token if (interfaceSymbol is null) return GeneratorResult.Empty; - if (interfaceSymbol.Arity > 0) + if (interfaceSymbol.Arity > 0 && !SharedHelpers.AreInterfaceTypeParametersSupported(interfaceSymbol)) { return GeneratorResult.Create( ReportableDiagnostic.Create( DiagnosticLibrary.General.GenericInterfacesNotSupported.Descriptor, isBlocking: true, - interfaceSymbol + interfaceSymbol, + interfaceSymbol.Name ) ); } @@ -85,11 +86,12 @@ is not InterfaceDeclarationSyntax interfaceDeclaration GenerationType: generationType, ClassNameToGenerate: className, ParentClasses: Utilities.GetParentClasses(interfaceDeclaration), - InterfaceType: TypeReference.Create(interfaceSymbol), + InterfaceType: SharedHelpers.GetInterfaceTypeReference(interfaceSymbol), ActivitySourceGenerationAttribute: activitySourceGenerationAttribute, ActivitySourceName: activitySourceName, ActivityMethods: activityMethods, - ActivityTargetAttributeRecord: activitySourceAttribute + ActivityTargetAttributeRecord: activitySourceAttribute, + InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol) ), TelemetryRules.GetInterfaceLevelDiagnostics(interfaceSymbol, compilation, token) ); @@ -112,37 +114,17 @@ CancellationToken token var prefix = GeneratePrefix(activitySourceGenerationAttribute, activitySourceAttribute, token); var defaultToTags = activitySourceGenerationAttribute?.DefaultToTags ?? activitySourceAttribute.DefaultToTags; - var lowercaseBaggageAndTagKeys = activitySourceAttribute.LowercaseBaggageAndTagKeys; + // The assembly-level value is the default and the interface-level value overrides it, which is the same + // precedence DefaultToTags uses. Before this, the assembly-level property was declared and modelled but + // never read, so setting it changed nothing. + var lowercaseBaggageAndTagKeys = activitySourceGenerationAttribute?.LowercaseBaggageAndTagKeys + ?? activitySourceAttribute.LowercaseBaggageAndTagKeys; List methodTargets = []; foreach (var method in GetAllInterfaceMethods(interfaceSymbol, token)) { token.ThrowIfCancellationRequested(); - if (method.Arity > 0) - { - // A generic method cannot be implemented for any target, so it is carried as a context method with - // an invalid generation state: the emitter reports it and generates a throw stub. - methodTargets.Add( - new ActivityContextMethod( - MethodName: method.Name, - ReturnType: TypeReference.Create(method.ReturnType), - ActivityOrEventName: method.Name, - HasActivityParameter: false, - Parameters: ImmutableArray.Empty, - Baggage: ImmutableArray.Empty, - Tags: ImmutableArray.Empty, - TargetGenerationState: new TargetGeneration( - IsValid: false, - RaiseInferenceNotSupportedWithMultiTargeting: false, - RaiseMultiGenerationTargetsNotSupported: false - ), - TypeParameters: ImmutableArray.CreateRange(method.TypeParameters, tp => tp.Name) - ) - ); - continue; - } - if (TypeHelpers.HasAttribute(method, TypeLibrary.Purview.Telemetry.ExcludeAttribute)) continue; @@ -188,6 +170,13 @@ out var eventAttribute // The payload decides the case, so an activity attribute and an event attribute can never both be set, // and a method that has neither is a context method rather than an activity with a null attribute. + var typeParameters = TypeParameterHelpers.FromMethod(method); + + // TSG1005: a type parameter whose constraint the generator cannot re-declare stops this method; the + // emitter writes a throw stub so the interface stays implemented. + if (!TypeParameterHelpers.AreSupported(method.TypeParameters)) + targetGenerationState = targetGenerationState with { IsValid = false }; + ActivityMethodTarget methodTarget = methodType == ActivityMethodType.Activity ? new StartActivityMethod( @@ -199,7 +188,8 @@ out var eventAttribute Baggage: baggageParameters, Tags: tagParameters, TargetGenerationState: targetGenerationState, - ActivityAttribute: activityAttribute + ActivityAttribute: activityAttribute, + TypeParameters: typeParameters ) : methodType == ActivityMethodType.Event ? new ActivityEventMethod( @@ -211,7 +201,8 @@ out var eventAttribute Baggage: baggageParameters, Tags: tagParameters, TargetGenerationState: targetGenerationState, - EventAttribute: eventAttribute + EventAttribute: eventAttribute, + TypeParameters: typeParameters ) : new ActivityContextMethod( MethodName: method.Name, @@ -221,7 +212,8 @@ out var eventAttribute Parameters: parameters, Baggage: baggageParameters, Tags: tagParameters, - TargetGenerationState: targetGenerationState + TargetGenerationState: targetGenerationState, + TypeParameters: typeParameters ); methodTargets.Add(methodTarget); @@ -244,7 +236,7 @@ CancellationToken token { token.ThrowIfCancellationRequested(); - var parameterType = TypeReference.Create(parameter.Type); + var parameterType = SharedHelpers.ToTypeReference(parameter.Type); var (destination, attribute) = GetParameterDestination(parameter, parameterType, defaultToTags, token); TagOrBaggageAttributeRecord? tagOrBaggageAttribute = null; diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs index 4fc2d61e..891d8b93 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Logger.cs @@ -30,13 +30,14 @@ CancellationToken token if (iLoggerTypeSymbol is null) return GeneratorResult.Empty; - if (interfaceSymbol.Arity > 0) + if (interfaceSymbol.Arity > 0 && !SharedHelpers.AreInterfaceTypeParametersSupported(interfaceSymbol)) { return GeneratorResult.Create( ReportableDiagnostic.Create( DiagnosticLibrary.General.GenericInterfacesNotSupported.Descriptor, isBlocking: true, - interfaceSymbol + interfaceSymbol, + interfaceSymbol.Name ) ); } @@ -54,8 +55,9 @@ CancellationToken token // Resolve the effective generation mode using priority: // interface GenerationMode > assembly GenerationMode > Auto (per-method decision) - var interfaceGenerationMode = - loggerAttribute.GenerationModeOrNull ?? loggerGenerationAttribute?.GenerationModeOrNull ?? 0; // Auto + var interfaceGenerationMode = loggerAttribute.GenerationModeOrNull + ?? loggerGenerationAttribute?.GenerationModeOrNull + ?? 0; // Auto var generationType = SharedHelpers.GetGenerationTypes(interfaceSymbol, token); if ( @@ -78,17 +80,24 @@ is not InterfaceDeclarationSyntax interfaceDeclaration token ); + // A [TagProvider] produces its tags at run time, which the LoggerMessage.Define-based emitter cannot carry. + // Rather than dropping them an interface that forced V1 is generated with the state-based emitter. + var requiresStateGeneration = logMethods.Any(static method => + method.Parameters.Any(static parameter => parameter.TagProvider != null) + ); + return GeneratorResult.Create( new( TelemetryGeneration: telemetryGeneration, GenerationType: generationType, ClassNameToGenerate: className, ParentClasses: Utilities.GetParentClasses(interfaceDeclaration), - InterfaceType: TypeReference.Create(interfaceSymbol), + InterfaceType: SharedHelpers.GetInterfaceTypeReference(interfaceSymbol), LoggerAttribute: loggerAttribute, DefaultLevel: defaultLogLevel, LogMethods: logMethods, - UseMSLoggingTelemetryBasedGeneration: interfaceGenerationMode != 1 // false only when V1 forced + UseMSLoggingTelemetryBasedGeneration: interfaceGenerationMode != 1 || requiresStateGeneration, + InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol) ), TelemetryRules.GetInterfaceLevelDiagnostics(interfaceSymbol, compilation, token) ); diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs index bcc0ad4a..9404856e 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Metrics.cs @@ -24,13 +24,14 @@ CancellationToken token if (interfaceSymbol is null) return GeneratorResult.Empty; - if (interfaceSymbol.Arity > 0) + if (interfaceSymbol.Arity > 0 && !SharedHelpers.AreInterfaceTypeParametersSupported(interfaceSymbol)) { return GeneratorResult.Create( ReportableDiagnostic.Create( DiagnosticLibrary.General.GenericInterfacesNotSupported.Descriptor, isBlocking: true, - interfaceSymbol + interfaceSymbol, + interfaceSymbol.Name ) ); } @@ -94,10 +95,16 @@ is not InterfaceDeclarationSyntax interfaceDeclaration GenerationType: generationType, ClassNameToGenerate: className, ParentClasses: Utilities.GetParentClasses(interfaceDeclaration), - InterfaceType: TypeReference.Create(interfaceSymbol), + InterfaceType: SharedHelpers.GetInterfaceTypeReference(interfaceSymbol), MeterName: meterName, MeterGeneration: meterGenerationAttribute, - InstrumentationMethods: instrumentMethods + InstrumentationMethods: instrumentMethods, + // The interface-level [Meter] overrides the assembly-level [MeterGeneration], the same precedence + // every other setting here uses. + MeterVersion: SharedHelpers.NullIfWhitespace(meterAttribute.Version) + ?? SharedHelpers.NullIfWhitespace(meterGenerationAttribute?.Version), + MeterTags: SharedHelpers.MergeTags(meterGenerationAttribute?.Tags, meterAttribute.Tags), + InterfaceTypeParameters: TypeParameterHelpers.FromNamedType(interfaceSymbol) ), TelemetryRules.GetInterfaceLevelDiagnostics(interfaceSymbol, compilation, token) ); diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.Logging.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.Logging.cs index ba8f89c0..15a506a8 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.Logging.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.Logging.cs @@ -143,6 +143,24 @@ out var attributeData return data.Exists ? data : null; } + public static TagProviderAttributeData? GetTagProviderAttribute(ISymbol symbol, CancellationToken token) + { + if ( + !Utilities.TryContainsAttribute( + symbol, + TypeLibrary.Microsoft.Extensions.Logging.TagProviderAttribute, + token, + out var attributeData + ) + ) + { + return null; + } + + var data = TagProviderAttributeData.FromAttributeData(attributeData!); + return data.Exists ? data with { ProviderMethod = NullIfWhitespace(data.ProviderMethod) } : null; + } + public static LoggerGenerationAttributeData? GetLoggerGenerationAttribute( Compilation compilation, CancellationToken token diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs index bb6a78d7..17577589 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.Metrics.cs @@ -5,6 +5,85 @@ namespace Purview.Telemetry.SourceGenerator.Helpers; partial class SharedHelpers { + /// + /// Merges the assembly-level and interface-level Tags settings into the ordered + /// key=value list a target carries. Both arrive as a comma-separated string because an attribute + /// property cannot be an array (the attribute-data model rejects string[] with ADM0002), so the + /// parse lives here once for both MeterOptions.Tags and ActivitySourceOptions.Tags. The + /// interface-level entry replaces the assembly-level one for the same key; a malformed entry is skipped. + /// + public static EquatableArray MergeTags(string? assemblyTags, string? interfaceTags) + { + List<(string Key, string Value)> merged = []; + AddTagEntries(assemblyTags, merged); + AddTagEntries(interfaceTags, merged); + + if (merged.Count == 0) + return new EquatableArray([]); + + List rendered = []; + foreach (var entry in merged) + rendered.Add(entry.Key + "=" + entry.Value); + + return new EquatableArray([.. rendered]); + } + + /// The entries in a Tags string that are not key=value, for diagnostics. + public static EquatableArray GetMalformedTags(string? tags) + { + if (string.IsNullOrWhiteSpace(tags)) + return new EquatableArray([]); + + List malformed = []; + + // netstandard2.0 reference assemblies do not annotate IsNullOrWhiteSpace, so the null state is restated. + var tagList = tags!; + + foreach (var entry in tagList.Split(',')) + { + var trimmed = entry.Trim(); + if (trimmed.Length == 0) + continue; + + var separator = trimmed.IndexOf('='); + if (separator <= 0 || separator == trimmed.Length - 1 || trimmed.Substring(0, separator).Trim().Length == 0) + malformed.Add(trimmed); + } + + return malformed.Count == 0 ? new EquatableArray([]) : new EquatableArray([.. malformed]); + } + + static void AddTagEntries(string? tags, List<(string Key, string Value)> entries) + { + if (string.IsNullOrWhiteSpace(tags)) + return; + + // netstandard2.0 reference assemblies do not annotate IsNullOrWhiteSpace, so the null state is restated. + var tagList = tags!; + + foreach (var entry in tagList.Split(',')) + { + var trimmed = entry.Trim(); + if (trimmed.Length == 0) + continue; + + var separator = trimmed.IndexOf('='); + if (separator <= 0 || separator == trimmed.Length - 1) + continue; + + var key = trimmed.Substring(0, separator).Trim(); + var value = trimmed.Substring(separator + 1).Trim(); + if (key.Length == 0) + continue; + + var existing = entries.FindIndex(candidate => candidate.Key == key); + if (existing >= 0) + entries[existing] = (key, value); + else + entries.Add((key, value)); + } + } + public static MeterGenerationAttributeData? GetMeterGenerationAttribute( Compilation compilation, CancellationToken token diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.cs index e3bb5e4a..53689aa3 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.cs @@ -23,6 +23,54 @@ public static GenerationType GetGenerationTypes(ISymbol symbol, CancellationToke return generationType; } + /// + /// Projects a type symbol into a TypeReference, including an open generic type parameter. + /// + /// + /// A type parameter cannot use the framework's TypeReference.ForTypeParameter here: the writer's + /// parameter validation requires a named identity, so a type-parameter reference is rejected as a parameter type. + /// A namespace-less identity renders as the bare parameter name and satisfies that check. + /// + public static TypeReference ToTypeReference(ITypeSymbol type) => + type is ITypeParameterSymbol typeParameter + ? new TypeReference(new TypeIdentity(typeParameter.Name, null)) + : TypeReference.Create(type); + + /// + /// Builds the type reference for the interface a generated class implements, including a generic interface's own + /// type parameters. + /// + /// + /// The framework's TypeReference.Create(ITypeSymbol) cannot name an open type parameter, so a generic + /// interface declaration is rebuilt from its name, namespace and type-parameter names. A nested generic interface + /// is reported as unsupported rather than half-built here. + /// + public static TypeReference GetInterfaceTypeReference(INamedTypeSymbol interfaceSymbol) + { + if (interfaceSymbol.Arity == 0) + return TypeReference.Create(interfaceSymbol); + + TypeIdentity identity = new( + interfaceSymbol.Name, + interfaceSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : interfaceSymbol.ContainingNamespace.ToDisplayString(), + interfaceSymbol.Arity + ); + + TypeReference[] typeArguments = [.. interfaceSymbol.TypeParameters.Select(ToTypeReference)]; + + return new TypeReference(identity.MakeGeneric(typeArguments)); + } + + /// + /// Whether every type parameter of an interface can be mirrored by the generated class. Only the + /// allows ref struct anti-constraint is out of reach, because it cannot be repeated on the generated + /// declaration. + /// + public static bool AreInterfaceTypeParametersSupported(INamedTypeSymbol interfaceSymbol) => + TypeParameterHelpers.AreSupported(interfaceSymbol.TypeParameters) && interfaceSymbol.ContainingType is null; + public static bool ShouldEmit(GenerationType requestingType, GenerationType generationType) { // For multi-targeting: emit if the interface has the requesting target type @@ -39,6 +87,48 @@ public static bool ShouldEmit(GenerationType requestingType, GenerationType gene public static bool IsTypeAvailable(Compilation compilation, TypeIdentity identity) => compilation.GetTypeByMetadataName(identity.MetadataFullName) is not null; + /// + /// Whether the compilation can resolve System.Diagnostics.Metrics.IMeterFactory. It arrives with + /// Microsoft.Extensions.Diagnostics.Abstractions (or the shared framework on .NET 8+), so a project can target a + /// modern framework and still not have it — the symbol probe, not the target framework, decides whether the + /// generated constructor may take one. + /// + public static bool IsMeterFactoryAvailable(Compilation compilation) => + IsTypeAvailable(compilation, TypeLibrary.System.Diagnostics.Metrics.IMeterFactory); + + /// + /// Whether the compilation can resolve Activity.AddException (.NET 9, or System.Diagnostics.DiagnosticSource + /// 9+ on any target). When it can, the generated exception event is produced by the framework, which names and + /// tags the event the way the runtime's own diagnostics do. + /// + public static bool IsActivityAddExceptionAvailable(Compilation compilation) => + compilation + .GetTypeByMetadataName(TypeLibrary.System.Diagnostics.Activity.MetadataFullName) + ?.GetMembers("AddException") + .Length > 0; + + /// + /// The assembly's version, used as the default meter and activity-source version when an attribute does not set + /// one. Read from the assembly identity, so [assembly: AssemblyVersion] and the SDK's own version + /// generation both feed it. Resolved once per compilation (see TelemetryCapabilities) rather than per + /// target, because the pipeline builds one capabilities value for the whole project. + /// + public static string? GetAssemblyVersion(Compilation compilation) + { + var version = compilation.Assembly.Identity.Version; + + // 0.0.0.0 is what the compiler supplies for a project that declares no version. Carrying it into + // MeterOptions.Version or ActivitySourceOptions.Version would report a meaningless version to every + // exporter, so an undeclared version stays absent and the emitted options keep their single-argument form. + if (version == default) + return null; + + if (version is { Major: 0, Minor: 0, Build: <= 0, Revision: <= 0 }) + return null; + + return version.ToString(); + } + /// /// Returns the single target that "owns" shared emission (DI extension, class attributes, /// constructor, throw stubs) for a multi-target interface. Only one target emits these to diff --git a/src/src/SourceGenerator/Helpers/TagProviderResolver.cs b/src/src/SourceGenerator/Helpers/TagProviderResolver.cs new file mode 100644 index 00000000..1fc849df --- /dev/null +++ b/src/src/SourceGenerator/Helpers/TagProviderResolver.cs @@ -0,0 +1,221 @@ +using Microsoft.CodeAnalysis; +using Purview.Telemetry.SourceGenerator.Records; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Why a [TagProvider] could not be used. Each value maps to one TSG rule. +/// +enum TagProviderStatus +{ + /// The provider was resolved to a usable method. + Resolved, + + /// The provider type named by the attribute cannot be resolved. + TypeNotFound, + + /// The provider type declares no method with the attribute's name. + MethodNotFound, + + /// The named method exists but cannot be used as a tag provider. + InvalidSignature, +} + +/// +/// The outcome of resolving a [TagProvider]: either a target the emitter can call, or the reason there is none. +/// +readonly record struct TagProviderResolution(TagProviderStatus Status, TagProviderTarget? Target) +{ + public static TagProviderResolution Failed(TagProviderStatus status) => new(status, null); +} + +/// +/// Resolves and validates [TagProvider(typeof(T), "M")] on a logging parameter. +/// +/// +/// Modelled on Microsoft.Gen.Logging's Parser.TagProvider, with one deliberate difference: the .NET 11 +/// collector shape (static void M(ITagCollector, T)) is not accepted, because implementing +/// ITagCollector in generated code would pull Microsoft.Extensions.Compliance.Classification into every +/// consumer's build. Only the pair-returning shape the generated state can consume is supported; a collector-shaped +/// method is reported as and recorded in +/// docs/wiki/Platform-Support.md. +/// +static class TagProviderResolver +{ + public static TagProviderResolution Resolve( + IParameterSymbol parameter, + TagProviderAttributeData attribute, + Compilation compilation, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); + + // An unresolvable `typeof(...)` leaves the model's TypeIdentity at its default, which names nothing. + if (string.IsNullOrWhiteSpace(attribute.ProviderType.Name)) + return TagProviderResolution.Failed(TagProviderStatus.TypeNotFound); + + var providerType = compilation.GetTypeByMetadataName(attribute.ProviderType.MetadataFullName); + if (providerType is null) + return TagProviderResolution.Failed(TagProviderStatus.TypeNotFound); + + if (attribute.ProviderMethod is not { } providerMethodName) + return TagProviderResolution.Failed(TagProviderStatus.MethodNotFound); + + var candidates = providerType.GetMembers(providerMethodName).OfType().ToArray(); + if (candidates.Length == 0) + return TagProviderResolution.Failed(TagProviderStatus.MethodNotFound); + + foreach (var candidate in candidates) + { + token.ThrowIfCancellationRequested(); + + if ( + !candidate.IsStatic + || candidate.IsGenericMethod + || candidate.Parameters.Length != 1 + || candidate.Parameters[0].RefKind != RefKind.None + ) + { + continue; + } + + if (!IsAcceptedReturnType(candidate.ReturnType)) + continue; + + if (!AcceptsParameter(candidate.Parameters[0].Type, parameter.Type)) + continue; + + if (!IsVisible(candidate)) + continue; + + return new( + TagProviderStatus.Resolved, + new( + ProviderTypeName: providerType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + MethodName: candidate.Name, + OmitReferenceName: attribute.OmitReferenceName + ) + ); + } + + return TagProviderResolution.Failed(TagProviderStatus.InvalidSignature); + } + + /// + /// Whether the method returns something the generated foreach can read as key/value pairs. Accepts + /// IEnumerable<KeyValuePair<string, object?>>, the string-valued variant, and any type + /// implementing one of them (KeyValuePair<string, object>[], List<...>, ...). + /// + static bool IsAcceptedReturnType(ITypeSymbol returnType) + { + if (TryGetEnumerableElementType(returnType, out var element) && IsAcceptedPair(element)) + return true; + + foreach (var candidate in returnType.AllInterfaces) + { + if (TryGetEnumerableElementType(candidate, out element) && IsAcceptedPair(element)) + return true; + } + + return false; + } + + static bool TryGetEnumerableElementType(ITypeSymbol type, out ITypeSymbol? element) + { + element = null; + + if (type is not INamedTypeSymbol named) + return false; + + if (named.OriginalDefinition.SpecialType != SpecialType.System_Collections_Generic_IEnumerable_T) + return false; + + element = named.TypeArguments[0]; + return true; + } + + static bool IsAcceptedPair(ITypeSymbol? element) + { + if (element is not INamedTypeSymbol { Arity: 2 } pair) + return false; + + if (pair.Name != "KeyValuePair") + return false; + + if (pair.ContainingNamespace?.ToDisplayString() != "System.Collections.Generic") + return false; + + if (pair.TypeArguments[0].SpecialType != SpecialType.System_String) + return false; + + // A nullability annotation is irrelevant to whether the pair can be read, so both `object?` and `object` count. + var valueType = pair.TypeArguments[1].WithNullableAnnotation(NullableAnnotation.None).ToDisplayString(); + + return valueType is "object" or "string"; + } + + /// + /// Whether the provider method's single parameter can accept the annotated parameter: the same type, a base type, + /// an implemented interface, object, or a type parameter the annotated type satisfies. + /// + static bool AcceptsParameter(ITypeSymbol providerParameterType, ITypeSymbol annotatedType) + { + if (providerParameterType is ITypeParameterSymbol) + return true; + + if (providerParameterType.SpecialType == SpecialType.System_Object) + return true; + + var providerType = providerParameterType.WithNullableAnnotation(NullableAnnotation.None); + for (var current = annotatedType; current is not null; current = current.BaseType) + { + if ( + SymbolEqualityComparer.Default.Equals( + current.WithNullableAnnotation(NullableAnnotation.None), + providerType + ) + ) + return true; + } + + foreach (var candidate in annotatedType.AllInterfaces) + { + if ( + SymbolEqualityComparer.Default.Equals( + candidate.WithNullableAnnotation(NullableAnnotation.None), + providerType + ) + ) + return true; + } + + return false; + } + + /// + /// Whether the provider method is reachable from generated code in the consumer's assembly. The .NET generator + /// walks the containing symbols and rejects private and protected members. + /// + static bool IsVisible(ISymbol symbol) + { + for ( + var current = symbol; + current is not null && current.Kind != SymbolKind.Namespace; + current = current.ContainingSymbol + ) + { + if ( + current.DeclaredAccessibility + is Accessibility.Private + or Accessibility.Protected + or Accessibility.ProtectedAndInternal + ) + { + return false; + } + } + + return true; + } +} diff --git a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Logging.cs b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Logging.cs index 0f4606a8..ee8a3847 100644 --- a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Logging.cs +++ b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Logging.cs @@ -22,4 +22,13 @@ partial class TypeLibraryGenerator [TypeRef(LoggingNamespace)] static readonly TypeIdentity LogPropertyIgnoreAttribute = default; + + /// + /// Microsoft.Extensions.Logging.TagProviderAttribute, shipped by Microsoft.Extensions.Telemetry.Abstractions. + /// The generator reads that type rather than declaring a Purview duplicate: a second + /// Purview.Telemetry.TagProviderAttribute would make every [TagProvider] ambiguous in a consumer that + /// imports both namespaces for ILogger and [Logger] (CS0104). + /// + [TypeRef(LoggingNamespace)] + static readonly TypeIdentity TagProviderAttribute = default; } diff --git a/src/src/SourceGenerator/Helpers/TypeParameterHelpers.cs b/src/src/SourceGenerator/Helpers/TypeParameterHelpers.cs new file mode 100644 index 00000000..7e3e0e89 --- /dev/null +++ b/src/src/SourceGenerator/Helpers/TypeParameterHelpers.cs @@ -0,0 +1,119 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework; +using Purview.Telemetry.SourceGenerator.Records; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Projects a Roslyn type parameter list into the value-equatable list a generated +/// method or class re-declares. +/// +/// +/// The supported constraints are the ones C# lets a generated declaration repeat: struct, class, +/// class?, notnull, unmanaged, new(), and type-parameter or type constraints. The +/// allows ref struct anti-constraint cannot be expressed on the generated declaration nor satisfied by the +/// generated state, so a type parameter carrying it is reported as unsupported rather than silently dropped. +/// +static class TypeParameterHelpers +{ + static readonly SymbolDisplayFormat ConstraintTypeFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public static EquatableArray FromMethod(IMethodSymbol method) => From(method.TypeParameters); + + public static EquatableArray FromNamedType(INamedTypeSymbol type) => From(type.TypeParameters); + + public static EquatableArray From(ImmutableArray typeParameters) + { + if (typeParameters.IsDefaultOrEmpty) + return default; + + List targets = new(typeParameters.Length); + foreach (var typeParameter in typeParameters) + targets.Add(From(typeParameter)); + + return new(targets.ToImmutableArray()); + } + + static TypeParameterTarget From(ITypeParameterSymbol typeParameter) + { + List constraints = []; + + if (typeParameter.HasReferenceTypeConstraint) + { + constraints.Add( + typeParameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated + ? "class?" + : "class" + ); + } + else if (typeParameter.HasValueTypeConstraint) + { + // `unmanaged` implies `struct`, and C# rejects them together. + constraints.Add(typeParameter.HasUnmanagedTypeConstraint ? "unmanaged" : "struct"); + } + else if (typeParameter.HasNotNullConstraint) + { + constraints.Add("notnull"); + } + + foreach (var constraintType in typeParameter.ConstraintTypes) + { + if (constraintType is IErrorTypeSymbol) + continue; + + constraints.Add(constraintType.ToDisplayString(ConstraintTypeFormat)); + } + + // `new()` is implied by a value-type constraint and rejected alongside it. + if (typeParameter.HasConstructorConstraint && !typeParameter.HasValueTypeConstraint) + constraints.Add("new()"); + + return new(typeParameter.Name, constraints.ToImmutableArray()); + } + + /// + /// Whether every type parameter can be re-declared by the generator. The only shape it cannot express today is the + /// allows ref struct anti-constraint, which neither the generated declaration nor the generated state can + /// carry. + /// + public static bool AreSupported(ImmutableArray typeParameters) + { + if (typeParameters.IsDefaultOrEmpty) + return true; + + foreach (var typeParameter in typeParameters) + { + if (typeParameter.AllowsRefLikeType) + return false; + } + + return true; + } + + public static ImmutableArray ToOptions( + EquatableArray typeParameters + ) + { + if (typeParameters.IsEmpty) + return []; + + var builder = ImmutableArray.CreateBuilder(typeParameters.Count); + + foreach (var typeParameter in typeParameters) + { + builder.Add( + new GenericTypeParameterOptions(typeParameter.Name) + { + Constraints = [.. typeParameter.Constraints.AsImmutableArray()], + } + ); + } + + return builder.MoveToImmutable(); + } +} diff --git a/src/src/SourceGenerator/Helpers/UnionHelpers.cs b/src/src/SourceGenerator/Helpers/UnionHelpers.cs index 0277554e..ba61b00a 100644 --- a/src/src/SourceGenerator/Helpers/UnionHelpers.cs +++ b/src/src/SourceGenerator/Helpers/UnionHelpers.cs @@ -1,30 +1,53 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Purview.Telemetry.SourceGenerator.Records; namespace Purview.Telemetry.SourceGenerator.Helpers; /// -/// Recognises C# 15 union types (a type carrying [System.Runtime.CompilerServices.Union]) used as -/// telemetry parameters, and describes how the generated code should read a case value out of one. +/// Recognises union types used as telemetry parameters, and describes how the generated code should read a case value +/// out of one. /// /// /// Detection is symbol based, never target-framework based: a union is a plain type declaration, so the -/// compilation resolves it the same way on net48 as on net11.0. What differs is only whether it declares -/// the non-boxing access pattern, which decides how the generated accessor reads it. +/// compilation resolves it the same way on net48 as on net11.0. What differs is only where the case list comes +/// from, and whether the union declares the non-boxing access pattern. +/// +/// Two unions are recognised, both by metadata name and namespace only — this assembly references neither package: +/// a type carrying [System.Runtime.CompilerServices.Union] (a union declaration or a hand-written +/// union), and a type carrying [Purview.Unions.GenerateUnion], whose members are emitted by the Purview.Unions +/// generator. Roslyn generators never see another generator's output, so for the second kind the case list has to +/// come from what is visible in source: the attribute's typeof list, the union's partial static +/// factory signatures, a nested IUnionMembers provider, or its constructors. +/// /// static class UnionHelpers { public const string UnionAttributeName = "UnionAttribute"; public const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; + + /// + /// Purview.Unions.GenerateUnionAttribute, matched by name so this generator needs no reference to the + /// Purview.Unions package. + /// + public const string GenerateUnionAttributeName = "GenerateUnionAttribute"; + + /// The namespace GenerateUnionAttribute lives in, matched by name for the same reason. + public const string PurviewUnionsNamespace = "Purview.Unions"; + public const string HasValueMemberName = "HasValue"; public const string TryGetValueMemberName = "TryGetValue"; public const string ValueMemberName = "Value"; public const string UnionMembersInterfaceName = "IUnionMembers"; public const string UnionMembersCreateMethodName = "Create"; + static readonly SymbolDisplayFormat CaseIdentityFormat = SymbolDisplayFormat.FullyQualifiedFormat; + /// /// Builds the for a telemetry parameter, or when - /// the parameter's type is not a union. + /// the parameter's type is not a union, or is a [GenerateUnion] target whose case list is not visible in + /// source. /// public static UnionTargetRecord? GetUnionTarget(ITypeSymbol parameterType, CancellationToken token) { @@ -52,7 +75,15 @@ static class UnionHelpers if (unionType is not INamedTypeSymbol namedUnionType) return null; - var caseTypes = GetCaseTypes(namedUnionType); + var isGeneratedUnion = HasAttribute(namedUnionType, GenerateUnionAttributeName, PurviewUnionsNamespace); + + var caseTypes = isGeneratedUnion + ? GetGeneratedUnionCaseTypes(namedUnionType, token) + : GetCaseTypes(namedUnionType); + + // A [GenerateUnion] target whose case list is not visible in source is not treated as a union at all: the + // unions generator reports its own UNI0003 for it, and recording a wrapper whose shape is unknown would be + // worse than recording it as an ordinary value. if (caseTypes.Count == 0) return null; @@ -62,30 +93,22 @@ static class UnionHelpers CaseTypes: new([.. caseTypes.Select(static caseType => TypeReference.Create(caseType))]), IsValueType: unionType.IsValueType, IsNullable: isNullable, - UsesNonBoxingAccess: UsesNonBoxingAccess(unionType, caseTypes.Count) + // The unions generator emits `HasValue` and one `TryGetValue(out T)` per case for every [GenerateUnion] + // surface, but emits them into generated output this generator never sees. The final compilation does see + // them, so the accessor is emitted against them. This is deliberately not extended to a [Union] type: some + // declare the pattern and some do not, so that stays a visible-member decision. + UsesNonBoxingAccess: isGeneratedUnion || UsesNonBoxingAccess(unionType, caseTypes.Count) ); } /// - /// Whether the type is a union: it carries [System.Runtime.CompilerServices.Union]. + /// Whether the type is a union: it carries [System.Runtime.CompilerServices.Union] or + /// [Purview.Unions.GenerateUnion]. Both are matched by metadata name and namespace, so this generator + /// depends on neither package. /// - public static bool IsUnion(ITypeSymbol type) - { - foreach (var attribute in type.GetAttributes()) - { - var attributeClass = attribute.AttributeClass; - if (attributeClass is null) - continue; - - if ( - attributeClass.Name == UnionAttributeName - && attributeClass.ContainingNamespace?.ToDisplayString() == CompilerServicesNamespace - ) - return true; - } - - return false; - } + public static bool IsUnion(ITypeSymbol type) => + HasAttribute(type, UnionAttributeName, CompilerServicesNamespace) + || HasAttribute(type, GenerateUnionAttributeName, PurviewUnionsNamespace); /// /// The case types a union can hold: the single-parameter Create members of a nested @@ -121,6 +144,260 @@ var constructor in unionType.InstanceConstructors.Where(static c => return caseTypes; } + /// + /// The case types of a [GenerateUnion] target, in the union generator's own order: the attribute's + /// typeof list, the union's partial static factory signatures, a nested IUnionMembers provider, then + /// any source-visible public single-parameter constructors. The first non-empty list wins, and every case is + /// normalised the way that generator exposes it. + /// + static List GetGeneratedUnionCaseTypes(INamedTypeSymbol unionType, CancellationToken token) + { + var candidates = GetAttributeCaseTypes(unionType); + if (candidates.Count == 0) + candidates = GetFactoryCaseTypes(unionType, token); + if (candidates.Count == 0) + candidates = GetProviderCaseTypes(unionType, token); + if (candidates.Count == 0) + candidates = GetConstructorCaseTypes(unionType, token); + + List caseTypes = []; + HashSet seen = [with(StringComparer.Ordinal)]; + + foreach (var candidate in candidates) + { + token.ThrowIfCancellationRequested(); + + if (!IsUsableCaseType(candidate)) + continue; + + var normalized = NormalizeCaseType(candidate, unionType); + if (normalized is null || !IsUsableCaseType(normalized)) + continue; + + // A duplicate case would emit a second, identical TryGetValue test. The unions generator reports + // duplicates itself, so keeping the first occurrence keeps the accessor deterministic and warning-free. + if (seen.Add(normalized.ToDisplayString(CaseIdentityFormat))) + caseTypes.Add(normalized); + } + + return caseTypes; + } + + /// + /// Whether a case can be named in generated code. A [GenerateUnion] whose case list names something that + /// cannot be a case is a broken union: the unions generator reports it, and this generator must degrade to "not a + /// union" rather than fail the whole build with TSG1000. + /// + static bool IsUsableCaseType(ITypeSymbol caseType) => + caseType.TypeKind is not (TypeKind.Error or TypeKind.Pointer or TypeKind.FunctionPointer or TypeKind.Dynamic) + && (caseType is ITypeParameterSymbol || TypeReference.TryCreate(caseType, out _)); + + /// + /// Reads the [GenerateUnion(typeof(...))] case list, in declaration order. + /// + static List GetAttributeCaseTypes(INamedTypeSymbol unionType) + { + foreach (var attribute in unionType.GetAttributes()) + { + if (!IsAttribute(attribute.AttributeClass, GenerateUnionAttributeName, PurviewUnionsNamespace)) + continue; + + // The attribute's only constructor takes the case types as a params array. + if (attribute.ConstructorArguments.Length != 1) + return []; + + var argument = attribute.ConstructorArguments[0]; + if (argument.Kind != TypedConstantKind.Array || argument.IsNull) + return []; + + List caseTypes = []; + foreach (var value in argument.Values) + { + if (value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol caseType) + caseTypes.Add(caseType); + } + + return caseTypes; + } + + return []; + } + + /// + /// Reads the case types from the union's own partial static factory signatures, in source order. + /// + static List GetFactoryCaseTypes(INamedTypeSymbol unionType, CancellationToken token) + { + List caseTypes = []; + + foreach (var method in OrderedFactoryMethods(unionType, token)) + caseTypes.Add(method.Parameters[0].Type); + + return caseTypes; + } + + /// + /// The partial static factory signatures to read cases from, in source order. + /// + /// + /// A factory is a static method with exactly one unrestricted parameter whose return type is the union, declared as + /// a partial signature without a body — the same rule the union generator applies, so the list read here is + /// the list it implements. + /// + static IEnumerable OrderedFactoryMethods(INamedTypeSymbol unionType, CancellationToken token) + { + foreach (var member in OrderedMembers(unionType)) + { + token.ThrowIfCancellationRequested(); + + if (member is not IMethodSymbol { IsStatic: true, Parameters.Length: 1 } method) + continue; + + if (method.Parameters[0].RefKind is not (RefKind.None or RefKind.In)) + continue; + + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, unionType)) + continue; + + if (!IsPartialMethodDefinition(method, token)) + continue; + + yield return method; + } + } + + /// + /// Reads the case types from a nested IUnionMembers provider's static Create methods, in source order. + /// + static List GetProviderCaseTypes(INamedTypeSymbol unionType, CancellationToken token) + { + List caseTypes = []; + + var provider = unionType + .GetTypeMembers(UnionMembersInterfaceName) + .FirstOrDefault(static member => !member.IsImplicitlyDeclared); + + if (provider is null) + return caseTypes; + + foreach (var member in OrderedMembers(provider)) + { + token.ThrowIfCancellationRequested(); + + if ( + member is IMethodSymbol { IsStatic: true, Parameters.Length: 1 } method + && method.Name == UnionMembersCreateMethodName + && method.Parameters[0].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals(method.ReturnType, unionType) + ) + { + caseTypes.Add(method.Parameters[0].Type); + } + } + + return caseTypes; + } + + /// + /// Reads the case types from the union's source-visible public single-parameter constructors. + /// + static List GetConstructorCaseTypes(INamedTypeSymbol unionType, CancellationToken token) + { + List caseTypes = []; + + foreach (var constructor in unionType.InstanceConstructors) + { + token.ThrowIfCancellationRequested(); + + if ( + constructor.DeclaredAccessibility != Accessibility.Public + || constructor.Parameters.Length != 1 + || constructor.Parameters[0].RefKind is not (RefKind.None or RefKind.In) + ) + { + continue; + } + + caseTypes.Add(constructor.Parameters[0].Type); + } + + return caseTypes; + } + + /// + /// Normalises a case type exactly as the union generator exposes it: a nullable value type becomes its underlying + /// type (int? → int), a nullable reference annotation is stripped, and constructed generic types and + /// arrays are preserved as they are. + /// + /// + /// The normalised case type, or for a bare type parameter that is not one of the union's + /// own, which the generated accessor could not name. + /// + static ITypeSymbol? NormalizeCaseType(ITypeSymbol caseType, INamedTypeSymbol unionType) + { + if ( + caseType is INamedTypeSymbol named + && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T + && named.TypeArguments.Length == 1 + ) + { + return NormalizeCaseType(named.TypeArguments[0], unionType); + } + + if (caseType is ITypeParameterSymbol typeParameter) + { + return unionType.TypeParameters.Contains(typeParameter, SymbolEqualityComparer.Default) ? caseType : null; + } + + return caseType.NullableAnnotation == NullableAnnotation.Annotated + ? caseType.WithNullableAnnotation(NullableAnnotation.NotAnnotated) + : caseType; + } + + static IEnumerable OrderedMembers(INamedTypeSymbol type) => + type.GetMembers() + .OrderBy(static member => + member.Locations.IsDefaultOrEmpty ? int.MaxValue : member.Locations[0].SourceSpan.Start + ); + + /// + /// Determines whether a method is a partial signature without a body, which is what makes a factory + /// declaration a case list rather than an implementation. + /// + static bool IsPartialMethodDefinition(IMethodSymbol method, CancellationToken token) + { + foreach (var reference in method.DeclaringSyntaxReferences) + { + if ( + reference.GetSyntax(token) is MethodDeclarationSyntax syntax + && syntax.Modifiers.Any(SyntaxKind.PartialKeyword) + && syntax.Body is null + && syntax.ExpressionBody is null + ) + { + return true; + } + } + + return false; + } + + static bool HasAttribute(ITypeSymbol type, string attributeName, string attributeNamespace) + { + foreach (var attribute in type.GetAttributes()) + { + if (IsAttribute(attribute.AttributeClass, attributeName, attributeNamespace)) + return true; + } + + return false; + } + + static bool IsAttribute(INamedTypeSymbol? attributeClass, string attributeName, string attributeNamespace) => + attributeClass is not null + && attributeClass.Name == attributeName + && attributeClass.ContainingNamespace?.ToDisplayString() == attributeNamespace; + /// /// Whether the union declares the non-boxing access pattern: a bool HasValue property plus a /// bool TryGetValue(out T) overload for every case type. When it does, the generated accessor diff --git a/src/src/SourceGenerator/Records/ActivityRecords.cs b/src/src/SourceGenerator/Records/ActivityRecords.cs index 06e79248..70ba604a 100644 --- a/src/src/SourceGenerator/Records/ActivityRecords.cs +++ b/src/src/SourceGenerator/Records/ActivityRecords.cs @@ -9,7 +9,8 @@ sealed record ActivitySourceTarget( ActivitySourceGenerationAttributeData? ActivitySourceGenerationAttribute, string? ActivitySourceName, EquatableArray ActivityMethods, - ActivitySourceAttributeData ActivityTargetAttributeRecord + ActivitySourceAttributeData ActivityTargetAttributeRecord, + EquatableArray InterfaceTypeParameters = default ) { public string? ClassNamespace => TelemetryGeneration.TelemetryNamesNamespace ?? InterfaceType.Identity.Namespace; @@ -58,7 +59,7 @@ public string? FullNamespace /// The parameters recorded as baggage. /// The parameters recorded as tags. /// The method's generation state. -/// The method's generic type parameters, when it is generic (and therefore invalid). +/// The method's generic type parameters and their constraints, empty for a non-generic method. closed record ActivityMethodTarget( string MethodName, TypeReference ReturnType, @@ -68,7 +69,7 @@ closed record ActivityMethodTarget( EquatableArray Baggage, EquatableArray Tags, TargetGeneration TargetGenerationState, - EquatableArray TypeParameters = default + EquatableArray TypeParameters = default ); /// A method that starts an activity: [Activity], or inferred from an Activity return type. @@ -82,7 +83,7 @@ sealed record StartActivityMethod( EquatableArray Tags, TargetGeneration TargetGenerationState, ActivityAttributeData? ActivityAttribute, - EquatableArray TypeParameters = default + EquatableArray TypeParameters = default ) : ActivityMethodTarget( MethodName, @@ -108,7 +109,7 @@ sealed record ActivityEventMethod( EquatableArray Tags, TargetGeneration TargetGenerationState, EventAttributeData? EventAttribute, - EquatableArray TypeParameters = default + EquatableArray TypeParameters = default ) : ActivityMethodTarget( MethodName, @@ -132,7 +133,7 @@ sealed record ActivityContextMethod( EquatableArray Baggage, EquatableArray Tags, TargetGeneration TargetGenerationState, - EquatableArray TypeParameters = default + EquatableArray TypeParameters = default ) : ActivityMethodTarget( MethodName, diff --git a/src/src/SourceGenerator/Records/AttributeDataModels.cs b/src/src/SourceGenerator/Records/AttributeDataModels.cs index 2682b1bc..2e79d173 100644 --- a/src/src/SourceGenerator/Records/AttributeDataModels.cs +++ b/src/src/SourceGenerator/Records/AttributeDataModels.cs @@ -30,7 +30,12 @@ readonly partial record struct ActivitySourceGenerationAttributeData( bool GenerateDiagnosticsForMissingActivity, [Property(DefaultValue = false)] bool UseActivitySourceFactory, [Property] string? Version, - [Property] string? TelemetrySchemaUrl + [Property] string? TelemetrySchemaUrl, + // ActivitySourceOptions.Tags: a comma-separated list of key=value pairs, because an attribute-data-model + // property cannot be an array (ADM0002). + [Argument("tags")] + [Property] + string? Tags ); [Generate("Purview.Telemetry.ActivityAttribute")] @@ -103,7 +108,10 @@ readonly partial record struct LogAttributeData( [Argument("messageTemplate")] [Property] string? MessageTemplate, [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, [Argument("name")] [Property] string? Name, - [Property(DefaultValue = 0)] int GenerationMode + [Property(DefaultValue = 0)] int GenerationMode, + // Parity with [LoggerMessage(SkipEnabledCheck = true)]: skip the IsEnabled guard when the caller has already + // checked the level, which saves a virtual call per log entry on a hot path. + [Property] bool SkipEnabledCheck ); [Generate("Purview.Telemetry.ExpandEnumerableAttribute")] @@ -190,6 +198,15 @@ readonly partial record struct LogPropertiesAttributeData( [Property] bool Transitive ); +[Generate("Microsoft.Extensions.Logging.TagProviderAttribute")] +readonly partial record struct TagProviderAttributeData( + // The provider type is carried as a TypeIdentity: a Type-valued attribute argument is not cacheable (ADM0010), and + // the TypeIdentity names the type without holding a Roslyn symbol in the pipeline model. + [Argument("providerType")] TypeIdentity ProviderType, + [Argument("providerMethod")] [Property] string? ProviderMethod, + [Property] bool OmitReferenceName +); + [Generate("Purview.Telemetry.MeterGenerationAttribute")] readonly partial record struct MeterGenerationAttributeData( [Argument("meterName")] [Property] string? MeterName, @@ -197,7 +214,13 @@ readonly partial record struct MeterGenerationAttributeData( [Argument("instrumentPrefix")] [Property] string? InstrumentPrefix, [Property(DefaultValue = ".")] string? InstrumentSeparator, [Argument("lowercaseInstrumentName")] [Property(DefaultValue = true)] bool LowercaseInstrumentName, - [Argument("lowercaseTagKeys")] [Property(DefaultValue = true)] bool LowercaseTagKeys + [Argument("lowercaseTagKeys")] [Property(DefaultValue = true)] bool LowercaseTagKeys, + // MeterOptions metadata. Version goes to MeterOptions.Version; each Tag is "key=value" and goes to + // MeterOptions.Tags. Scope stays unreachable: it takes an object instance that an attribute cannot carry. + [Argument("version")] + [Property] + string? Version, + [Argument("tags")] [Property] string? Tags ); [Generate("Purview.Telemetry.MeterAttribute")] @@ -206,7 +229,9 @@ readonly partial record struct MeterAttributeData( [Argument("instrumentPrefix")] [Property] string? InstrumentPrefix, [Property(DefaultValue = true)] bool IncludeAssemblyInstrumentPrefix, [Property(DefaultValue = true)] bool LowercaseInstrumentName, - [Property(DefaultValue = true)] bool LowercaseTagKeys + [Property(DefaultValue = true)] bool LowercaseTagKeys, + [Argument("version")] [Property] string? Version, + [Argument("tags")] [Property] string? Tags ); [Generate("Purview.Telemetry.CounterAttribute")] diff --git a/src/src/SourceGenerator/Records/GenericRecords.cs b/src/src/SourceGenerator/Records/GenericRecords.cs index cfa49976..852f2868 100644 --- a/src/src/SourceGenerator/Records/GenericRecords.cs +++ b/src/src/SourceGenerator/Records/GenericRecords.cs @@ -14,6 +14,22 @@ enum GenerationType All = Activities | Logging | Metrics, } +/// +/// One generic type parameter a generated method (or class) must re-declare, with the constraint expressions written +/// after where T :. +/// +/// +/// The constraints are already-rendered C# expressions (struct, class?, notnull, unmanaged, +/// new(), or a fully-qualified constraint type) rather than symbols, so the value stays equatable and usable in +/// the incremental pipeline. +/// +/// The type parameter's name. +/// The ordered constraint expressions, empty when the parameter is unconstrained. +readonly record struct TypeParameterTarget(string Name, EquatableArray Constraints) +{ + public bool HasConstraints => !Constraints.IsEmpty; +} + readonly record struct TargetGeneration( bool IsValid, bool RaiseInferenceNotSupportedWithMultiTargeting, diff --git a/src/src/SourceGenerator/Records/LoggerRecords.cs b/src/src/SourceGenerator/Records/LoggerRecords.cs index 011ce6f1..04e3abbe 100644 --- a/src/src/SourceGenerator/Records/LoggerRecords.cs +++ b/src/src/SourceGenerator/Records/LoggerRecords.cs @@ -9,7 +9,8 @@ sealed record LoggerTarget( LoggerAttributeData LoggerAttribute, int DefaultLevel, EquatableArray LogMethods, - bool UseMSLoggingTelemetryBasedGeneration + bool UseMSLoggingTelemetryBasedGeneration, + EquatableArray InterfaceTypeParameters = default ) { public string? ClassNamespace => TelemetryGeneration.TelemetryNamesNamespace ?? InterfaceType.Identity.Namespace; @@ -61,12 +62,16 @@ sealed record LogMethodTarget( TargetGeneration TargetGenerationState, bool UseV1Generation, bool HasExplicitLevel = false, - bool HasLogPropertiesAndExpandEnumerable = false + bool HasLogPropertiesAndExpandEnumerable = false, + bool SkipEnabledCheck = false, + EquatableArray TypeParameters = default ) { public int TotalParameterCount => Parameters.Count; public int ParameterCountSansException => ParametersSansException.Count; + + public bool IsGeneric => !TypeParameters.IsEmpty; } sealed record LogParameterTarget( @@ -83,7 +88,8 @@ sealed record LogParameterTarget( ExpandEnumerableAttributeData? ExpandEnumerableAttribute, GenerationType ExcludedTargets, EquatableArray ReferencedHoles = default, - UnionTargetRecord? Union = null + UnionTargetRecord? Union = null, + TagProviderTarget? TagProvider = null ) { public bool UsedInTemplate => !ReferencedHoles.IsEmpty; @@ -102,6 +108,34 @@ sealed record LogParameterTarget( public bool RequiresObjectState => Union is not null; } -readonly record struct LogPropertiesParameterDetails(string PropertyName, bool IsNullable); +/// +/// One property, or one step of a transitive property path, flattened out of a [LogProperties] parameter. +/// +/// +/// The tag name beneath the parameter, dot-separated for a transitive path: TemperatureC, or +/// Location.City for a nested property. +/// +/// Whether the property's value can be null, which decides SkipNullProperties. +/// +/// The expression appended to the parameter name to read the value, including the null-conditional operators the +/// chain needs: ?.TemperatureC, or ?.Location?.City. +/// +readonly record struct LogPropertiesParameterDetails(string PropertyName, bool IsNullable, string? Accessor = null) +{ + public string ValueAccessor => Accessor ?? ("." + PropertyName); +} + +/// +/// A resolved [TagProvider]: the static method whose returned pairs are added to the log state as extra tags. +/// +/// +/// The provider type's fully-qualified name, including the global:: prefix the emitters rely on. +/// +/// The static provider method's name. +/// +/// Whether the parameter name is left off the contributed tag names, matching the same-named +/// [TagProvider] property. +/// +readonly record struct TagProviderTarget(string ProviderTypeName, string MethodName, bool OmitReferenceName); readonly record struct LogLevelDetails(TypeIdentity LevelType, int Value, string Name); diff --git a/src/src/SourceGenerator/Records/MetricAttributeRecords.cs b/src/src/SourceGenerator/Records/MetricAttributeRecords.cs index 76e17793..b1d3cb06 100644 --- a/src/src/SourceGenerator/Records/MetricAttributeRecords.cs +++ b/src/src/SourceGenerator/Records/MetricAttributeRecords.cs @@ -1,8 +1,8 @@ namespace Purview.Telemetry.SourceGenerator.Records; /// -/// The instrument a metrics method declares: exactly one of the eight System.Diagnostics.Metrics instruments, -/// so the hierarchy is closed and a switch over it is exhaustive. +/// The instrument a metrics method declares: exactly one of the seven System.Diagnostics.Metrics instrument +/// types, so the hierarchy is closed and a switch over it is exhaustive. /// /// /// Each case knows the Meter factory method that creates it, the instrument type it produces and how it diff --git a/src/src/SourceGenerator/Records/MetricRecords.cs b/src/src/SourceGenerator/Records/MetricRecords.cs index 9d81d286..ca7ae618 100644 --- a/src/src/SourceGenerator/Records/MetricRecords.cs +++ b/src/src/SourceGenerator/Records/MetricRecords.cs @@ -8,7 +8,10 @@ sealed record MeterTarget( TypeReference InterfaceType, string? MeterName, MeterGenerationAttributeData? MeterGeneration, - EquatableArray InstrumentationMethods + EquatableArray InstrumentationMethods, + string? MeterVersion = null, + EquatableArray MeterTags = default, + EquatableArray InterfaceTypeParameters = default ) { public string? ClassNamespace => TelemetryGeneration.TelemetryNamesNamespace ?? InterfaceType.Identity.Namespace; @@ -53,10 +56,13 @@ sealed record InstrumentTarget( EquatableArray Parameters, EquatableArray Tags, InstrumentParameterTarget? MeasurementParameter, - TargetGeneration TargetGenerationState + TargetGeneration TargetGenerationState, + EquatableArray TypeParameters = default ) { public string TagPopulateMethodName { get; } = $"Populate{MethodName}Tags"; + + public bool IsGeneric => !TypeParameters.IsEmpty; } sealed record InstrumentParameterTarget( diff --git a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs index ccbcc203..f36b5b59 100644 --- a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs +++ b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs @@ -9,7 +9,11 @@ namespace Purview.Telemetry.SourceGenerator.Records; /// assemblies access to internals. /// /// -/// Whether meters are created through an injected IMeterFactory (.NET 8+; never .NET Framework). +/// Whether meters are created through an injected IMeterFactory. Probed on the compilation, never on the target +/// framework: IMeterFactory arrives with Microsoft.Extensions.Diagnostics.Abstractions (or the shared framework on +/// .NET 8+), so a .NET Framework project that references that package has it too, and a modern project that pins an +/// older line may not. A consumer with the generated factory-taking constructor must register the factory +/// (services.AddMetrics()). /// /// /// Whether activity sources are created through an injected System.Diagnostics.ActivitySourceFactory: the @@ -33,5 +37,7 @@ sealed record TelemetryCapabilities( bool SupportsIMeterFactory, bool UseActivitySourceFactory = false, bool SupportsGauge = false, - bool SupportsInstrumentAdvice = false + bool SupportsInstrumentAdvice = false, + bool SupportsActivityAddException = false, + string? AssemblyVersion = null ) : IGenerationCapabilities; diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs index dfa848d6..625f290a 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs @@ -57,6 +57,16 @@ CancellationToken token "TelemetrySchemaUrl" ) ); + + if (!string.IsNullOrWhiteSpace(target.ActivitySourceGenerationAttribute?.Tags)) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Activities.ActivitySourceOptionsRequireFactory.Descriptor, + isBlocking: false, + interfaceSymbol, + "Tags" + ) + ); } // TSG3001: no activity source specified. @@ -84,8 +94,10 @@ CancellationToken token ) ); - var generateDiagnosticsForMissingActivity = - target.ActivitySourceGenerationAttribute?.GenerateDiagnosticsForMissingActivity ?? true; + var generateDiagnosticsForMissingActivity = target + .ActivitySourceGenerationAttribute + ?.GenerateDiagnosticsForMissingActivity + ?? true; foreach (var method in target.ActivityMethods) { @@ -185,8 +197,7 @@ CancellationToken token // set as baggage, and a parameter excluded from the Activities target is not applied at all. if (method is ActivityEventMethod activityEventMethod) { - var useRecordExceptionRules = - activityEventMethod.EventAttribute?.UseRecordExceptionRules + var useRecordExceptionRules = activityEventMethod.EventAttribute?.UseRecordExceptionRules ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; var recordsException = diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs b/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs index 58ff3330..d6411ae7 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs @@ -26,8 +26,9 @@ CancellationToken token var loggerAttribute = SharedHelpers.GetLoggerAttribute(interfaceSymbol, token); var loggerGenerationAttribute = SharedHelpers.GetLoggerGenerationAttribute(compilation.Assembly, token); - var interfaceGenerationMode = - loggerAttribute?.GenerationModeOrNull ?? loggerGenerationAttribute?.GenerationModeOrNull ?? 0; + var interfaceGenerationMode = loggerAttribute?.GenerationModeOrNull + ?? loggerGenerationAttribute?.GenerationModeOrNull + ?? 0; var methods = PipelineHelpers .GetInstanceMethods(interfaceSymbol) @@ -44,7 +45,7 @@ CancellationToken token continue; } - ApplyLoggerMethodRules(method, interfaceGenerationMode, diagnostics, token); + ApplyLoggerMethodRules(method, compilation, interfaceGenerationMode, diagnostics, token); } return diagnostics.ToImmutable(); @@ -52,6 +53,7 @@ CancellationToken token static void ApplyLoggerMethodRules( IMethodSymbol method, + Compilation compilation, int interfaceGenerationMode, ImmutableArray.Builder diagnostics, CancellationToken token @@ -140,7 +142,7 @@ CancellationToken token foreach (var parameter in method.Parameters) { token.ThrowIfCancellationRequested(); - ApplyParameterRules(parameter, diagnostics, token); + ApplyParameterRules(parameter, compilation, diagnostics, token); } // Message-template rules (only when an explicit template is supplied). @@ -150,12 +152,42 @@ CancellationToken token static void ApplyParameterRules( IParameterSymbol parameter, + Compilation compilation, ImmutableArray.Builder diagnostics, CancellationToken token ) { var logPropertiesAttribute = SharedHelpers.GetLogPropertiesAttribute(parameter, token); var expandEnumerableAttribute = SharedHelpers.GetExpandEnumerableAttribute(parameter, token); + var tagProviderAttribute = SharedHelpers.GetTagProviderAttribute(parameter, token); + + ApplyTagProviderRules(parameter, tagProviderAttribute, compilation, diagnostics, token); + + // TSG2022 / TSG2023: parity with the .NET 11 [LoggerMessage] generator (SYSLIB1028/SYSLIB1029). These shapes + // cannot be represented in the generated state: the parameter list is re-declared without modifiers, a + // `params` array would stop accepting the params form, and a by-ref or ref-struct value cannot be captured by + // LoggerMessage.Define or a typed state struct. Rejecting them says so up front instead of emitting code + // that fails to compile or silently changes the call syntax. + if (parameter.IsParams) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Logging.ParamsParameterNotSupported.Descriptor, + isBlocking: false, + parameter + ) + ); + } + else if (parameter.RefKind != RefKind.None || parameter.Type.IsRefLikeType) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Logging.ByRefParameterNotSupported.Descriptor, + isBlocking: false, + parameter + ) + ); + } // TSG2006: LogProperties + ExpandEnumerable on the same parameter. if (logPropertiesAttribute != null && expandEnumerableAttribute != null) @@ -186,6 +218,54 @@ CancellationToken token } } + static void ApplyTagProviderRules( + IParameterSymbol parameter, + TagProviderAttributeData? tagProviderAttribute, + Compilation compilation, + ImmutableArray.Builder diagnostics, + CancellationToken token + ) + { + if (tagProviderAttribute is not { } attribute) + return; + + var resolution = TagProviderResolver.Resolve(parameter, attribute, compilation, token); + var providerTypeName = attribute.ProviderType.Name; + var methodName = attribute.ProviderMethod ?? string.Empty; + + // A successful resolution needs nothing reported; the generator emits the provider's tags. + var diagnostic = resolution.Status switch + { + TagProviderStatus.TypeNotFound => ReportableDiagnostic.Create( + DiagnosticLibrary.Logging.TagProviderTypeNotFound.Descriptor, + isBlocking: false, + parameter, + parameter.Name, + providerTypeName + ), + TagProviderStatus.MethodNotFound => ReportableDiagnostic.Create( + DiagnosticLibrary.Logging.TagProviderMethodNotFound.Descriptor, + isBlocking: false, + parameter, + parameter.Name, + methodName, + providerTypeName + ), + TagProviderStatus.InvalidSignature => ReportableDiagnostic.Create( + DiagnosticLibrary.Logging.TagProviderMethodInvalidSignature.Descriptor, + isBlocking: false, + parameter, + methodName, + providerTypeName, + parameter.Type.Name + ), + _ => null, + }; + + if (diagnostic is { } reportable) + diagnostics.Add(reportable); + } + static void ApplyMessageTemplateRules( IMethodSymbol method, string messageTemplate, diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs b/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs index b71fd734..327a7004 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Metrics.cs @@ -51,7 +51,7 @@ CancellationToken token // TSG4011: histogram bucket boundaries need InstrumentAdvice. if ( instrument.InstrumentAttribute - is HistogramInstrumentAttributeRecord { HistogramBucketBoundaries: { Count: > 0 } } + is HistogramInstrumentAttributeRecord { HistogramBucketBoundaries.Count: > 0 } && !supportsInstrumentAdvice ) diagnostics.Add( diff --git a/src/src/SourceGenerator/Records/TelemetryRules.cs b/src/src/SourceGenerator/Records/TelemetryRules.cs index ca0aaa6e..b539a5f9 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.cs @@ -37,6 +37,10 @@ public static ImmutableArray GetAllSupportedDescriptors() DiagnosticLibrary.Logging.ScopedMethodShouldNotHaveLevel.Descriptor, DiagnosticLibrary.Logging.UnboundedIEnumerableMaxCount.Descriptor, DiagnosticLibrary.Logging.LogMustReturnVoidOrAsync.Descriptor, + DiagnosticLibrary.Logging.TagProviderTypeNotFound.Descriptor, + DiagnosticLibrary.Logging.TagProviderMethodNotFound.Descriptor, + DiagnosticLibrary.Logging.TagProviderMethodInvalidSignature.Descriptor, + DiagnosticLibrary.Logging.TagProviderNotOnLoggingMethod.Descriptor, DiagnosticLibrary.Activities.BaggageParameterShouldBeString.Descriptor, DiagnosticLibrary.Activities.NoActivitySourceSpecified.Descriptor, DiagnosticLibrary.Activities.InvalidReturnType.Descriptor, @@ -130,14 +134,16 @@ CancellationToken token ) ); - // TSG1004: generic interface - nothing further is meaningful. - if (interfaceSymbol.Arity > 0) + // TSG1004: a generic interface whose type parameters cannot be mirrored, or a nested one. A plain generic + // interface is generated as a generic class with the same type parameters and constraints. + if (interfaceSymbol.Arity > 0 && !SharedHelpers.AreInterfaceTypeParametersSupported(interfaceSymbol)) { diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.General.GenericInterfacesNotSupported.Descriptor, isBlocking: true, - interfaceSymbol + interfaceSymbol, + interfaceSymbol.Name ) ); return diagnostics.ToImmutable(); @@ -179,7 +185,7 @@ CancellationToken token } // Gather methods grouped by name (for TSG1003 and per-method checks). - Dictionary> methodsByName = new(StringComparer.Ordinal); + Dictionary> methodsByName = [with(StringComparer.Ordinal)]; foreach (var method in PipelineHelpers.GetInstanceMethods(interfaceSymbol)) { if (!methodsByName.TryGetValue(method.Name, out var list)) @@ -235,17 +241,38 @@ CancellationToken token if (TypeHelpers.HasAttribute(method, TypeLibrary.Purview.Telemetry.ExcludeAttribute)) return; - // TSG1005: generic method. + // TSG1005: a generic method the generator cannot implement. Generic methods are supported; what is not is a + // constraint the generated declaration cannot repeat (`allows ref struct`) or a measurement whose type + // parameter is declared by the method rather than the interface. if (method.Arity > 0) { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.General.GenericMethodsNotSupported.Descriptor, - isBlocking: false, - method - ) - ); - return; + var typeParameters = method.TypeParameters; + if (!TypeParameterHelpers.AreSupported(typeParameters)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.General.GenericMethodsNotSupported.Descriptor, + isBlocking: false, + method, + method.Name, + "the 'allows ref struct' anti-constraint cannot be repeated on the generated declaration" + ) + ); + } + else if ( + generationType.HasFlag(GenerationType.Metrics) && UsesMethodTypeParameterAsMeasurement(method, token) + ) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.General.GenericMethodsNotSupported.Descriptor, + isBlocking: false, + method, + method.Name, + "a type parameter declared by the method cannot be the instrument measurement, because the instrument is a field; declare the type parameter on the interface instead" + ) + ); + } } var targetState = Utilities.IsValidGenerationTarget(method, generationType, generationType); @@ -286,8 +313,7 @@ targetState.ActivityParameterWithoutTarget is { } activityParameterName ) { var parameterSymbol = FindParameter(method, activityParameterName); - var activityParameterLocation = - parameterSymbol?.Locations.FirstOrDefault(static l => l.IsInSource) + var activityParameterLocation = parameterSymbol?.Locations.FirstOrDefault(static l => l.IsInSource) ?? method.Locations.FirstOrDefault(static l => l.IsInSource) ?? Location.None; @@ -303,6 +329,115 @@ targetState.ActivityParameterWithoutTarget is { } activityParameterName // TSG1006/TSG1007: [ExcludeTargets] validation against the method's target families. ApplyExcludeTargetsRules(method, targetState.MethodTargets, diagnostics, token); + + // TSG2027: [TagProvider] on a method that never produces a log entry. + ApplyTagProviderTargetRules(method, generationType, targetState, diagnostics, token); + } + + /// + /// Whether a metric method's measurement parameter is one of the method's own type parameters, which cannot be + /// carried by the generated instrument field. + /// + static bool UsesMethodTypeParameterAsMeasurement(IMethodSymbol method, CancellationToken token) + { + foreach (var parameter in method.Parameters) + { + token.ThrowIfCancellationRequested(); + + var resolved = parameter.Type; + + if (resolved is ITypeParameterSymbol directTypeParameter) + { + if ( + method.TypeParameters.Contains(directTypeParameter, SymbolEqualityComparer.Default) + && directTypeParameter.HasValueTypeConstraint + ) + { + return true; + } + + continue; + } + + if (resolved is INamedTypeSymbol { Arity: 1 } func && new TypeIdentity(typeof(Func<>)).Matches(func)) + { + resolved = func.TypeArguments[0]; + + if ( + resolved is INamedTypeSymbol { Arity: 1 } measurement + && TypeLibrary.System.Diagnostics.Metrics.Measurement.Equals(measurement.ConstructedFrom) + ) + { + resolved = measurement.TypeArguments[0]; + } + else if ( + resolved is INamedTypeSymbol { Arity: 1 } enumerable + && TypeLibrary.System.Collections.Generic.IEnumerable.Equals(enumerable.ConstructedFrom) + && enumerable.TypeArguments[0] is INamedTypeSymbol { Arity: 1 } nested + && TypeLibrary.System.Diagnostics.Metrics.Measurement.Equals(nested.ConstructedFrom) + ) + { + resolved = nested.TypeArguments[0]; + } + } + + if ( + resolved is ITypeParameterSymbol typeParameter + && typeParameter.HasValueTypeConstraint + && method.TypeParameters.Contains(typeParameter, SymbolEqualityComparer.Default) + ) + { + return true; + } + } + + return false; + } + + static void ApplyTagProviderTargetRules( + IMethodSymbol method, + GenerationType interfaceGenerationType, + TargetGeneration targetState, + ImmutableArray.Builder diagnostics, + CancellationToken token + ) + { + if (!interfaceGenerationType.HasFlag(GenerationType.Logging)) + { + ReportTagProvidersOnNonLoggingMethod(method, diagnostics, token); + return; + } + + // A method with no explicit attributes is inferred for every registered target, so it does log. + if (targetState.MethodTargets == GenerationType.None) + return; + + if (!targetState.MethodTargets.HasFlag(GenerationType.Logging)) + ReportTagProvidersOnNonLoggingMethod(method, diagnostics, token); + } + + static void ReportTagProvidersOnNonLoggingMethod( + IMethodSymbol method, + ImmutableArray.Builder diagnostics, + CancellationToken token + ) + { + foreach (var parameter in method.Parameters) + { + token.ThrowIfCancellationRequested(); + + if (SharedHelpers.GetTagProviderAttribute(parameter, token) is null) + continue; + + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Logging.TagProviderNotOnLoggingMethod.Descriptor, + isBlocking: false, + parameter, + parameter.Name + ) + ); + } } static void ApplyExcludeTargetsRules( @@ -405,19 +540,20 @@ CancellationToken token ) ); - if (interfaceSymbol.Arity > 0) + if (interfaceSymbol.Arity > 0 && !SharedHelpers.AreInterfaceTypeParametersSupported(interfaceSymbol)) { diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.General.GenericInterfacesNotSupported.Descriptor, isBlocking: true, - interfaceSymbol + interfaceSymbol, + interfaceSymbol.Name ) ); return diagnostics.ToImmutable(); } - Dictionary> methodsByName = new(StringComparer.Ordinal); + Dictionary> methodsByName = [with(StringComparer.Ordinal)]; foreach (var method in PipelineHelpers.GetInstanceMethods(interfaceSymbol)) { if (!methodsByName.TryGetValue(method.Name, out var list)) diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs index aad03656..37b0ad0d 100644 --- a/src/tests/Repository.UnitTests/InstalledSkillTests.cs +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -10,13 +10,28 @@ namespace Purview.Telemetry.Repository; /// .agents/skills/, never copies. /// /// +/// /// SkillTests validates this repository's own skills in skills/; these are someone else's, /// so their content is not checked here, only that the installed set is the recorded set. +/// +/// +/// are the exception: skills written here for people working on the +/// generator. They are not consumer guidance and are never published, so they cannot live in +/// skills/ (which the skills CLI ships) or in the lockfile (which the CLI writes). They live in +/// .agents/skills/ because that is the one folder every agent reads. +/// /// public sealed class InstalledSkillTests { static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + /// + /// Skills committed here for this repository's own developers. Each is authored in place, is not in + /// skills-lock.json (no CLI wrote it) and must never appear in skills/, because anything + /// there is published to consumers. + /// + static readonly string[] DeveloperSkills = ["purview-telemetry-benchmarks"]; + [Test] public async Task Lockfile_GivenEveryEntry_HasItsSkillInstalledUnderAgents(CancellationToken cancellationToken) { @@ -50,6 +65,7 @@ public async Task AgentsSkills_GivenEveryInstalledSkill_IsRecordedInTheLockfile( .GetDirectories(AgentsSkillsFolder()) .Select(static path => Path.GetFileName(path)) .Where(name => !recorded.Contains(name, StringComparer.Ordinal)) + .Where(name => !DeveloperSkills.Contains(name, StringComparer.Ordinal)) .ToArray(); // Assert @@ -58,7 +74,36 @@ await Assert .IsEmpty() .Because( "every folder in .agents/skills/ must be written by the skills CLI, which records it in " - + $"skills-lock.json: {string.Join(", ", unrecorded)}" + + "skills-lock.json, or declared in InstalledSkillTests.DeveloperSkills as a " + + $"developer skill of this repository: {string.Join(", ", unrecorded)}" + ); + } + + [Test] + public async Task DeveloperSkills_GivenEachOne_IsPresentAndNeverPublished(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + + // Act: a developer skill has to be loadable by an agent, and it must not also be a consumer skill. + var notLoadable = DeveloperSkills + .Where(static name => !File.Exists(Path.Combine(AgentsSkillsFolder(), name, "SKILL.md"))) + .ToArray(); + var published = DeveloperSkills + .Where(static name => Directory.Exists(Path.Combine(RepositoryRoot.Value, "skills", name))) + .ToArray(); + + // Assert + await Assert + .That(notLoadable) + .IsEmpty() + .Because($"a developer skill must ship its SKILL.md in .agents/skills/: {string.Join(", ", notLoadable)}"); + await Assert + .That(published) + .IsEmpty() + .Because( + "a developer skill is for this repository only, so it must never sit in skills/, which the " + + $"skills CLI publishes to consumers: {string.Join(", ", published)}" ); } diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs index a6523c49..e02d6795 100644 --- a/src/tests/Repository.UnitTests/ReleaseGateTests.cs +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -195,7 +195,7 @@ CancellationToken cancellationToken { // Arrange using TemporaryDirectory output = new(); - var path = directoryExists ? output.Path : System.IO.Path.Combine(output.Path, "missing"); + var path = directoryExists ? output.Path : Path.Combine(output.Path, "missing"); // Act var result = await RunLeakScanAsync([path], cancellationToken); @@ -218,10 +218,7 @@ CancellationToken cancellationToken ) { // Arrange - var workflows = Directory.GetFiles( - System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows"), - "*.yml" - ); + var workflows = Directory.GetFiles(Path.Combine(RepositoryRoot.Value, ".github", "workflows"), "*.yml"); // Act List building = []; @@ -237,7 +234,7 @@ CancellationToken cancellationToken ) continue; - var name = $"{System.IO.Path.GetFileName(workflow)}:{job}"; + var name = $"{Path.GetFileName(workflow)}:{job}"; building.Add(name); if (!body.Contains("./" + LeakScanScript, StringComparison.Ordinal)) unscanned.Add(name); @@ -262,7 +259,7 @@ CancellationToken cancellationToken { // Arrange var text = await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), cancellationToken ); @@ -293,7 +290,7 @@ CancellationToken cancellationToken { // Arrange var text = await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), cancellationToken ); @@ -336,10 +333,7 @@ CancellationToken cancellationToken ) { // Arrange - var lines = await File.ReadAllLinesAsync( - System.IO.Path.Combine(RepositoryRoot.Value, script), - cancellationToken - ); + var lines = await File.ReadAllLinesAsync(Path.Combine(RepositoryRoot.Value, script), cancellationToken); // Act var last = lines.Reverse().FirstOrDefault(static line => !string.IsNullOrWhiteSpace(line))?.Trim(); @@ -362,23 +356,69 @@ CancellationToken cancellationToken ) { // Arrange - var text = await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, "Justfile"), - cancellationToken - ); + var text = await File.ReadAllTextAsync(Path.Combine(RepositoryRoot.Value, "Justfile"), cancellationToken); // Act - var match = Regex.Match( - text, - $@"^{Regex.Escape(recipe)}\b[^\n]*:\r?\n(?(?:[ \t]+[^\n]*\r?\n?)+)", - RegexOptions.Multiline - ); + var match = Recipe(text, recipe); // Assert await Assert.That(match.Success).IsTrue().Because($"the Justfile has no '{recipe}' recipe"); await Assert.That(match.Groups["body"].Value).Contains($"pwsh -NoProfile -File {script}"); } + /// + /// Every version this package declares is a lower bound, so the floor it picks becomes the floor every + /// consumer inherits. A floor is a claim until something builds against it: Directory.Packages.props + /// keeps one property per package family plus a single DependencyFloor switch, just test-floor + /// proves it locally and the dependency-floor PR job proves it on every change. + /// + [Test] + public async Task DependencyFloor_GivenEachPackageFamily_IsSwitchedAndProvedByARecipeAndAPrJob( + CancellationToken cancellationToken + ) + { + // Arrange + var packages = XDocument.Parse( + await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot.Value, "Directory.Packages.props"), + cancellationToken + ) + ); + var justfile = await File.ReadAllTextAsync(Path.Combine(RepositoryRoot.Value, "Justfile"), cancellationToken); + var workflow = await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + cancellationToken + ); + + // Act + var families = packages + .Descendants() + .Where(static element => element.Name.LocalName is "DotnetRuntimeVersion" or "MSExtensionVersion") + .ToArray(); + var ordinary = families.Count(static element => + ((string?)element.Attribute("Condition"))?.Contains("!= 'true'", StringComparison.Ordinal) == true + ); + var floor = families.Count(static element => + ((string?)element.Attribute("Condition"))?.Contains("== 'true'", StringComparison.Ordinal) == true + ); + var recipe = Recipe(justfile, "test-floor"); + var job = Jobs(workflow).FirstOrDefault(static candidate => candidate.Name == "dependency-floor"); + + // Assert: two package families, each with an ordinary and a floor version, so a family cannot be + // bumped halfway and the floor cannot be a second file that drifts. + await Assert.That(families.Length).IsEqualTo(4); + await Assert.That(ordinary).IsEqualTo(2); + await Assert.That(floor).IsEqualTo(2); + + await Assert.That(recipe.Success).IsTrue().Because("the Justfile has no 'test-floor' recipe"); + await Assert.That(recipe.Groups["body"].Value).Contains("-p:DependencyFloor=true"); + await Assert + .That(job.Name) + .IsEqualTo("dependency-floor") + .Because("the PR workflow has no 'dependency-floor' job"); + await Assert.That(job.Body).Contains("-p:DependencyFloor=true"); + } + /// /// The packed-consumer gate proves what ships only while its consumers reference the package: a ProjectReference /// to the repository's generator would hand them the unmerged generator and its bin folder instead. @@ -389,7 +429,7 @@ CancellationToken cancellationToken ) { // Arrange - var consumers = System.IO.Path.Combine(RepositoryRoot.Value, "scripts", "packed-consumers"); + var consumers = Path.Combine(RepositoryRoot.Value, "scripts", "packed-consumers"); var projects = Directory.GetFiles(consumers, "*.csproj", SearchOption.AllDirectories); // Act @@ -397,7 +437,7 @@ CancellationToken cancellationToken foreach (var project in projects) { var document = XDocument.Parse(await File.ReadAllTextAsync(project, cancellationToken)); - var name = System.IO.Path.GetRelativePath(consumers, project); + var name = Path.GetRelativePath(consumers, project); var generator = document .Descendants("PackageReference") @@ -415,10 +455,8 @@ CancellationToken cancellationToken foreach (var reference in document.Descendants("ProjectReference")) { var include = (string?)reference.Attribute("Include") ?? string.Empty; - var resolved = System.IO.Path.GetFullPath( - System.IO.Path.Combine(System.IO.Path.GetDirectoryName(project)!, include) - ); - if (!resolved.StartsWith(consumers + System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal)) + var resolved = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(project)!, include)); + if (!resolved.StartsWith(consumers + Path.DirectorySeparatorChar, StringComparison.Ordinal)) problems.Add($"{name} references {include}, outside scripts/packed-consumers"); } } @@ -440,7 +478,7 @@ CancellationToken cancellationToken { // Arrange var json = await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), + Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), cancellationToken ); using var document = JsonDocument.Parse( @@ -501,7 +539,7 @@ CancellationToken cancellationToken // Arrange var document = XDocument.Parse( await File.ReadAllTextAsync( - System.IO.Path.Combine(RepositoryRoot.Value, "src", "Directory.Build.props"), + Path.Combine(RepositoryRoot.Value, "src", "Directory.Build.props"), cancellationToken ) ); @@ -545,8 +583,8 @@ static TemporaryDirectory CleanOutput() static void Write(string root, OutputFile file) { - var path = System.IO.Path.Combine(root, file.RelativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + var path = Path.Combine(root, file.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); var bytes = Encoding.UTF8.GetBytes(file.Content); if (!file.Brotli) { @@ -570,7 +608,7 @@ static async Task RunLeakScanAsync( "-NoProfile", "-NonInteractive", "-File", - System.IO.Path.Combine(RepositoryRoot.Value, LeakScanScript), + Path.Combine(RepositoryRoot.Value, LeakScanScript), "-Path", .. paths, .. AllowArguments(allow), @@ -608,6 +646,21 @@ sealed record ScanResult(int ExitCode, string StandardOutput, string StandardErr [GeneratedRegex(@"^ (?[A-Za-z0-9_-]+):[ \t]*\r?$", RegexOptions.Multiline)] private static partial Regex JobHeader(); + /// One recipe of the Justfile, matched by name together with its indented body. + static Match Recipe(string justfile, string name) + { + foreach (Match candidate in RecipeBlock().Matches(justfile)) + { + if (candidate.Groups["name"].Value == name) + return candidate; + } + + return Match.Empty; + } + + [GeneratedRegex(@"^(?[A-Za-z0-9_-]+)[^\n]*:\r?\n(?(?:[ \t]+[^\n]*\r?\n?)+)", RegexOptions.Multiline)] + private static partial Regex RecipeBlock(); + static string FindRepositoryRoot() { for ( @@ -617,8 +670,8 @@ static string FindRepositoryRoot() ) { if ( - File.Exists(System.IO.Path.Combine(directory.FullName, "AGENTS.md")) - && File.Exists(System.IO.Path.Combine(directory.FullName, LeakScanScript)) + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, LeakScanScript)) ) return directory.FullName; } diff --git a/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs index ab3411c3..e1926603 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Analyzers/TelemetryDiagnosticAnalyzerTests.cs @@ -11,7 +11,27 @@ public class TelemetryDiagnosticAnalyzerTests : TUnitDiagnosticAnalyzerTestBase { [Test] - public async Task Analyze_GivenGenericActivityInterface_RaisesGenericInterfaceDiagnostic( + public async Task Analyze_GivenNestedGenericActivityInterface_RaisesGenericInterfaceDiagnostic( + CancellationToken cancellationToken + ) + { + const string code = """ + public class Outer + { + [ActivitySource] + public interface ITestActivities + { + } + } + """; + + var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); + + await Assert.That(result).HasDiagnostic("TSG1004"); + } + + [Test] + public async Task Analyze_GivenGenericActivityInterface_DoesNotRaiseGenericInterfaceDiagnostic( CancellationToken cancellationToken ) { @@ -24,7 +44,7 @@ public interface ITestActivities var result = await AnalyzeAsync(code, new TelemetryAnalyzerTestOptions(), cancellationToken); - await Assert.That(result).HasDiagnostic("TSG1004"); + await Assert.That(result).DoesNotHaveDiagnostic("TSG1004"); } [Test] diff --git a/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs index 546d96a0..489681b5 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs @@ -66,6 +66,32 @@ CancellationToken cancellationToken await Assert.That(diagnostics.Any(static diagnostic => diagnostic.Id == "TSG1011")).IsTrue(); } + [Test] + [Arguments("NET;NET8_0;NET8_0_OR_GREATER;NETCOREAPP")] + [Arguments("NET;NET8_0;NET8_0_OR_GREATER;NET9_0;NET9_0_OR_GREATER;NETCOREAPP")] + public async Task Analyze_GivenDroppedNet8OrNet9Compilation_RaisesTSG1011( + string preprocessorSymbols, + CancellationToken cancellationToken + ) + { + // Arrange: the exact symbol set the SDK defines for net8.0 and net9.0, including the + // NET_OR_GREATER chain that a later version defines as well. .NET 8 and .NET 9 are no + // longer supported, so the compilation must be reported rather than silently generate nothing. + var compilation = CreateCompilation(preprocessorSymbols.Split(';')); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + var messages = diagnostics + .Where(static diagnostic => diagnostic.Id == "TSG1011") + .Select(static diagnostic => diagnostic.GetMessage(CultureInfo.InvariantCulture)) + .ToArray(); + + await Assert.That(messages.Length).IsEqualTo(1); + await Assert.That(messages[0]).IsEqualTo(ExpectedMessage); + } + [Test] [Arguments("NET;NET10_0;NET10_0_OR_GREATER;NETCOREAPP")] [Arguments("NETFRAMEWORK;NET48;NET48_OR_GREATER")] diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs new file mode 100644 index 00000000..9a51bc49 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.AddException.cs @@ -0,0 +1,147 @@ +using Microsoft.CodeAnalysis.CSharp; +using Purview.SourceGeneratorFramework; +using Purview.Telemetry.SourceGenerator.Emitters; + +namespace Purview.Telemetry.SourceGenerator.Activities; + +partial class TelemetrySourceGeneratorActivityTests +{ + const string ActivitySourceWithEvent = """ + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities + { + [Activity] + System.Diagnostics.Activity? Activity(); + + [Event] + void Event(System.Diagnostics.Activity? activity, string name); + } + """; + + [Test] + public async Task Generate_GivenActivitySource_EmitsAddExceptionWhenTheCompilationSupportsIt( + CancellationToken cancellationToken + ) + { + // Arrange / Act + using var generationResult = await GenerateAsync(ActivitySourceWithEvent, cancellationToken: cancellationToken); + + // Assert + var source = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(source) + .ContainsGeneratedCode("activity.AddException(exception, exceptionTags, default)") + .Because("the framework should produce the exception event when Activity.AddException exists"); + await Assert + .That(source) + .ContainsGeneratedCode("\"exception.escaped\"") + .Because("the escape flag is ours to supply through the TagList AddException accepts"); + await Assert + .That(source) + .DoesNotContain("recordExceptionEvent") + .Because("the hand-built ActivityEvent is the fallback, not the preferred path"); + } + + [Test] + public async Task Generate_GivenActivitySource_DoesNotDuplicateTheFrameworkExceptionTags( + CancellationToken cancellationToken + ) + { + // Arrange / Act + using var generationResult = await GenerateAsync(ActivitySourceWithEvent, cancellationToken: cancellationToken); + + // Assert: AddException writes exception.type/message/stacktrace itself. Emitting them too would either + // duplicate the key or, worse, shadow the framework's values. + var source = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(source) + .DoesNotContain("exception.type") + .Because("exception.type is written by Activity.AddException"); + await Assert + .That(source) + .DoesNotContain("exception.message") + .Because("exception.message is written by Activity.AddException"); + await Assert + .That(source) + .DoesNotContain("exception.stacktrace") + .Because("exception.stacktrace is written by Activity.AddException"); + } + + [Test] + public async Task EmitRecordExceptionBody_GivenBothCapabilityStates_EmitsCompilingCodeWithTheSameTagSet( + CancellationToken cancellationToken + ) + { + // Arrange + using var generationResult = await GenerateAsync(ActivitySourceWithEvent, cancellationToken: cancellationToken); + var compilation = generationResult.CompilationResult.Compilation; + + var addExceptionBody = EmitRecordExceptionBodyForTest(supportsActivityAddException: true); + var fallbackBody = EmitRecordExceptionBodyForTest(supportsActivityAddException: false); + + // Assert: the escape flag is the one tag both paths write. + await Assert + .That(addExceptionBody) + .Contains("\"exception.escaped\"") + .Because("the framework path still reports whether the exception escaped"); + await Assert + .That(fallbackBody) + .Contains("\"exception.escaped\"") + .Because("the hand-built event reports whether the exception escaped"); + await Assert + .That(addExceptionBody) + .Contains("activity.AddException(exception, exceptionTags, default)") + .Because("the supported path defers event creation to the framework"); + await Assert + .That(fallbackBody) + .Contains("recordExceptionEvent") + .Because("the fallback must still be emitted when the probe fails"); + + // Assert: both shapes compile against the same references the generated code has. + await Assert.That(CompileErrors(compilation, addExceptionBody)).IsEmpty(); + await Assert.That(CompileErrors(compilation, fallbackBody)).IsEmpty(); + } + + static string EmitRecordExceptionBodyForTest(bool supportsActivityAddException) + { + CodeWriter writer = new(GenerationSettings.Create(), throwOnUnclosedScopes: false); + ActivitySourceTargetClassEmitter.EmitRecordExceptionBody(writer, supportsActivityAddException); + + return writer.ToString(); + } + + static IReadOnlyList CompileErrors(Compilation compilation, string body) + { + var source = + """ + #nullable enable + + internal static class ExceptionBodyHost + { + internal static void RecordExceptionInternal( + global::System.Diagnostics.Activity? activity, + global::System.Exception? exception, + bool escape + ) + { + """ + + body + + """ + } + } + """; + + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + var updated = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions)); + + return + [ + .. updated + .GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(static diagnostic => diagnostic.ToString()), + ]; + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.GenericInterfaces.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.GenericInterfaces.cs new file mode 100644 index 00000000..7124ca9e --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.GenericInterfaces.cs @@ -0,0 +1,29 @@ +namespace Purview.Telemetry.SourceGenerator.Activities; + +partial class TelemetrySourceGeneratorActivityTests +{ + [Test] + public async Task Generate_GivenGenericActivityInterface_GeneratesAGenericClass(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities where T : struct + { + [Activity] + System.Diagnostics.Activity? Start(T value); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert.That(generated).Contains("class TestActivitiesCore"); + await Assert.That(generated).Contains("where T : struct"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.Generics.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.Generics.cs new file mode 100644 index 00000000..9ae1e35b --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.Generics.cs @@ -0,0 +1,34 @@ +namespace Purview.Telemetry.SourceGenerator.Activities; + +partial class TelemetrySourceGeneratorActivityTests +{ + [Test] + public async Task Generate_GivenGenericActivityMethod_RecordsTheTypeParameterAsAValue( + CancellationToken cancellationToken + ) + { + // Arrange: StartActivity accepts object? tags, so a generic parameter is recorded as its value. + const string source = """ + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities + { + [Activity] + System.Diagnostics.Activity? Start(T value) where T : struct; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1005"); + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .Contains("Start(T value)") + .Because("the generated activity method mirrors the interface's type parameter"); + await Assert.That(generated).Contains("where T : struct"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.cs index 2d4866eb..0be46ea1 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.cs @@ -54,7 +54,7 @@ await Assert [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenInterfaceWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenInterfaceWithGenerics_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -82,12 +82,12 @@ public interface ITestActivities<{genericTypeDef}> ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenMethodWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenMethodWithGenerics_GeneratesAGenericMethod( int genericTypeCount, CancellationToken cancellationToken ) @@ -108,13 +108,13 @@ public interface ITestActivities "; // Act - using var generationResult = await GenerateAsync( - basicActivity, - TelemetrySourceGeneratorTestOptions.NoValidation, - cancellationToken: cancellationToken - ); + using var generationResult = await GenerateAsync(basicActivity, cancellationToken: cancellationToken); - // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1005"); + // Assert: a generic method is generated with the interface's type parameters mirrored, not rejected. + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1005"); + await Assert + .That(generationResult.GetSource("TestActivitiesCore.Activity.g.cs")) + .Contains($"Activity<{genericTypeDef}>") + .Because("the generated implementation must declare the same type parameters as the interface method"); } } diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorGeneratedUnionTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorGeneratedUnionTests.cs new file mode 100644 index 00000000..31f79027 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorGeneratedUnionTests.cs @@ -0,0 +1,681 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Generators; + +/// +/// Union parameters whose type is a [Purview.Unions.GenerateUnion] target. The unions generator emits the +/// [Union] attribute, the constructors, HasValue and one TryGetValue(out T) per case into +/// generated output, which Roslyn never shows another generator. So the telemetry generator has to read the case list +/// from what is in source — the attribute's typeof list, the partial static factory signatures, a +/// nested IUnionMembers provider, or the constructors — and then emit the accessor against the members that will +/// exist once both generators have run. +/// +/// +/// The union generator is not referenced from this repository, so each test declares a stand-in +/// Purview.Unions.GenerateUnionAttribute and a union with source-visible HasValue/TryGetValue/ +/// Value members, which is what makes the emitted accessor compile when only this generator runs. The case list +/// still has to come from the [GenerateUnion] source surface, which is what the assertions pin. +/// +/// The real two-generator integration test — Purview.Unions and this generator in one compilation — is a follow-up: the +/// package is not on a CI-safe feed yet, so it cannot be a ProjectReference or PackageReference here. +/// +/// +[SkipOnNetFramework] // the test sources need records and nullable annotations, whose support types are not in the .NET Framework box. +public partial class TelemetrySourceGeneratorGeneratedUnionTests + : IncrementalSourceGeneratorTestBase +{ + /// + /// The stand-in Purview.Unions.GenerateUnionAttribute, matched by name and namespace. Its constructor + /// mirrors the real one: a params Type[] case list. + /// + const string GenerateUnionAttribute = """ + + using System; + + namespace Purview.Unions + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] + public sealed class GenerateUnionAttribute : Attribute + { + public GenerateUnionAttribute(params Type[] caseTypes) => CaseTypes = caseTypes; + + public Type[] CaseTypes { get; } + } + } + + """; + + const string CaseTypes = """ + + namespace Testing + { + public record class Cat(string Name); + + public record class Dog(string Name); + + public record class Bird(string Name); + } + + """; + + /// + /// Surface A: the case list is the attribute's typeof list. The union also has a public single-parameter + /// constructor for a type that is not a case, so the assertions prove the attribute list wins rather than + /// the constructors being picked up. + /// + const string SurfaceAUnion = """ + + namespace Testing + { + [Purview.Unions.GenerateUnion(typeof(Cat), typeof(Dog))] + public partial struct Pet + { + readonly object? _value; + + Pet(object? value) => _value = value; + + public Pet(Bird value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Cat value) + { + if (_value is Cat cat) + { + value = cat; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out Dog value) + { + if (_value is Dog dog) + { + value = dog; + return true; + } + + value = null!; + return false; + } + } + } + + """; + + [Test] + public async Task Generate_GivenGeneratedUnionSurfaceA_UnwrapsTheCaseValueForActivitiesAndMetrics( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + GenerateUnionAttribute + + CaseTypes + + SurfaceAUnion + + """ + + namespace Testing + { + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet, [Baggage] Pet baggage); + } + + [Meter("testing-meter")] + public interface ITestMetrics + { + [Counter] + void Increment(int value, [Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var activity = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(activity).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(activity).Contains("value.TryGetValue(out global::Testing.Cat caseValue0)"); + await Assert.That(activity).Contains("value.TryGetValue(out global::Testing.Dog caseValue1)"); + await Assert.That(activity).Contains("SetTag(\"pet\", UnwrapUnion_Testing_Pet(pet))"); + await Assert.That(activity).Contains("SetBaggage(\"baggage\", UnwrapUnion_Testing_Pet(baggage)?.ToString())"); + + // The case list came from the attribute, not from the union's constructors. + await Assert + .That(activity) + .DoesNotContain("global::Testing.Bird") + .Because("only the attribute's typeof list supplies cases"); + + var metric = generationResult.GetSource("Metric.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(metric).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(metric).Contains(", UnwrapUnion_Testing_Pet(pet))"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionSurfaceAWithV2Logging_LogsTheCaseValue( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + GenerateUnionAttribute + + CaseTypes + + SurfaceAUnion + + """ + + namespace Testing + { + [Logger(GenerationMode = LoggerGenerationMode.V2)] + public interface ITestLogger + { + [Info("Pet {Pet}")] + void LogPet(Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Logging.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("new LogPet_LogState(UnwrapUnion_Testing_Pet(pet))"); + await Assert.That(generated).Contains("object? _Pet"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionSurfaceAWithV1Logging_LogsTheCaseValue( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + GenerateUnionAttribute + + CaseTypes + + SurfaceAUnion + + """ + + namespace Testing + { + [Logger(GenerationMode = LoggerGenerationMode.V1)] + public interface ITestLogger + { + [Info("Pet {Pet}")] + void LogPet(Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Logging.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("Define"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionSurfaceB_ReadsTheFactoryCases(CancellationToken cancellationToken) + { + // Arrange: no attribute case list, so the partial static factory signatures are the case list. The factories + // carry an implementing part so the stand-in compiles without the unions generator; the definition part is what + // makes them a case list. + var source = + GenerateUnionAttribute + + CaseTypes + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion] + public partial struct Pet + { + public static partial Pet Create(Cat value); + + public static partial Pet Create(Dog value); + + public static partial Pet Create(Cat value) => new(value); + + public static partial Pet Create(Dog value) => new(value); + + readonly object? _value; + + Pet(object? value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Cat value) + { + if (_value is Cat cat) + { + value = cat; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out Dog value) + { + if (_value is Dog dog) + { + value = dog; + return true; + } + + value = null!; + return false; + } + } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Cat caseValue0)"); + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Dog caseValue1)"); + await Assert.That(generated).Contains("SetTag(\"pet\", UnwrapUnion_Testing_Pet(pet))"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionSurfaceC_ReadsTheProviderCases(CancellationToken cancellationToken) + { + // Arrange: a nested IUnionMembers provider is the portable route when another generator also needs the case + // list. The union also has a constructor for a non-case type, so the provider is what supplies the list. + var source = + GenerateUnionAttribute + + CaseTypes + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion] + public partial struct Pet + { + public interface IUnionMembers + { + static Pet Create(Cat value) => new(value); + + static Pet Create(Dog value) => new(value); + } + + readonly object? _value; + + Pet(object? value) => _value = value; + + public Pet(Bird value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out Cat value) + { + if (_value is Cat cat) + { + value = cat; + return true; + } + + value = null!; + return false; + } + + public bool TryGetValue(out Dog value) + { + if (_value is Dog dog) + { + value = dog; + return true; + } + + value = null!; + return false; + } + } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("UnwrapUnion_Testing_Pet"); + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Cat caseValue0)"); + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Dog caseValue1)"); + await Assert.That(generated).DoesNotContain("global::Testing.Bird"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionWithNoVisibleCases_RecordsTheUnionAsAnOrdinaryValue( + CancellationToken cancellationToken + ) + { + // Arrange: no attribute list, no factories, no provider, no constructors. The unions generator reports UNI0003 + // for this, so telemetry stays silent and records the value as it would any other. + var source = + GenerateUnionAttribute + + CaseTypes + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion] + public partial struct Pet { } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + await Assert.That(generationResult).HasNoErrorDiagnostics(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).DoesNotContain("UnwrapUnion_"); + await Assert.That(generated).Contains("SetTag(\"pet\", pet)"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionWithNullableCases_NormalisesThemAsTheUnionGeneratorDoes( + CancellationToken cancellationToken + ) + { + // Arrange: a nullable value type is exposed through its underlying type, so the case is int and the accessor + // reads TryGetValue(out int). A nullable reference annotation is not part of a case's identity. + var source = + GenerateUnionAttribute + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion(typeof(int?), typeof(string))] + public partial struct Pet + { + readonly object? _value; + + Pet(object? value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out int value) + { + if (_value is int number) + { + value = number; + return true; + } + + value = 0; + return false; + } + + public bool TryGetValue(out string value) + { + if (_value is string text) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("value.TryGetValue(out int caseValue0)"); + await Assert.That(generated).Contains("value.TryGetValue(out string caseValue1)"); + await Assert.That(generated).DoesNotContain("int? caseValue"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionWithNullableReferenceFactoryCase_StripsTheAnnotation( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + GenerateUnionAttribute + + CaseTypes + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion] + public partial struct Pet + { + public static partial Pet Create(string? value); + + public static partial Pet Create(string? value) => new(value); + + readonly object? _value; + + Pet(object? value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out string value) + { + if (_value is string text) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("value.TryGetValue(out string caseValue0)"); + await Assert.That(generated).DoesNotContain("string? caseValue"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionWithAResolvableCaseList_DoesNotFailOnABrokenCaseType( + CancellationToken cancellationToken + ) + { + // Arrange: a typeof argument that names nothing makes the union broken. The unions generator owns that error, + // so telemetry must degrade to "not a union" rather than fail the build with TSG1000. + var source = + GenerateUnionAttribute + + CaseTypes + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion(typeof(DoesNotExist))] + public partial struct Pet { } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync( + source, + TelemetrySourceGeneratorTestOptions.NoValidation, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1000"); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).DoesNotContain("UnwrapUnion_"); + await Assert.That(generated).Contains("SetTag(\"pet\", pet)"); + } + + [Test] + public async Task Generate_GivenGeneratedUnionWhoseVisibleMembersLackThePattern_StillReadsTryGetValue( + CancellationToken cancellationToken + ) + { + // Arrange: the stand-in declares HasValue plus a single generic TryGetValue, which the visible-member + // detection would reject for a two-case union. Reads here prove the accessor is emitted for a [GenerateUnion] + // target on the strength of what the unions generator will emit, not on what is visible now. + var source = + GenerateUnionAttribute + + CaseTypes + + """ + + namespace Testing + { + [Purview.Unions.GenerateUnion(typeof(Cat), typeof(Dog))] + public partial struct Pet + { + readonly object? _value; + + Pet(object? value) => _value = value; + + public object? Value => _value; + + public bool HasValue => _value is not null; + + public bool TryGetValue(out T value) + { + if (_value is T typed) + { + value = typed; + return true; + } + + value = default!; + return false; + } + } + + [ActivitySource("testing")] + public interface ITestActivities + { + [Activity] + void DoWork([Tag] Pet pet); + } + } + + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + generationResult.AssertNoCompilationErrors(); + + var generated = generationResult.GetSource("Activity.g.cs", HintNameMatchMode.Suffix); + + await Assert.That(generated).Contains("value.TryGetValue(out global::Testing.Cat caseValue0)"); + await Assert.That(generated).DoesNotContain("return value.Value;"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.GenericInterfaces.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.GenericInterfaces.cs new file mode 100644 index 00000000..e2cf53ef --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.GenericInterfaces.cs @@ -0,0 +1,105 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Logging; + +partial class TelemetrySourceGeneratorLoggingGen2Tests +{ + [Test] + public async Task Generate_GivenGenericLoggerInterface_MirrorsTheTypeParametersOntoTheClass( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger where T : class + { + void Log(T value); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(generated) + .Contains("class TestLoggerCore") + .Because("the generated class is generic over the interface's type parameters"); + await Assert + .That(generated) + .Contains("global::Testing.ITestLogger") + .Because("the implemented interface carries the same type argument"); + await Assert + .That(generated) + .Contains("where T : class") + .Because("the interface's constraint is repeated on the generated class"); + } + + [Test] + public async Task Generate_GivenGenericInterfaceWithDependenciesExtension_RegistersTheOpenGeneric( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void Log(T value); + } + """; + + // Act + using var generationResult = await GenerateAsync( + source, + GenerateDependencyInjection(), + cancellationToken: cancellationToken + ); + + // Assert + await Assert + .That(generationResult.GetSource("TestLoggerCoreDIExtension.DependencyInjection.g.cs")) + .Contains("typeof(global::Testing.ITestLogger<>)") + .Because("an open generic interface can only be registered in its open form"); + } + + [Test] + public async Task Generate_GivenGenericInterfaceWithUnsupportedConstraint_RaisesTSG1004( + CancellationToken cancellationToken + ) + { + // Arrange: 'allows ref struct' cannot be repeated on the generated class. + const string source = """ + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger where T : allows ref struct + { + void Log(TValue value); + } + """; + + // Act + using var generationResult = await GenerateAsync( + source, + TelemetrySourceGeneratorTestOptions.NoValidation, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(generationResult).HasDiagnostic("TSG1004"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.Generics.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.Generics.cs new file mode 100644 index 00000000..21fb9e76 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.Generics.cs @@ -0,0 +1,104 @@ +namespace Purview.Telemetry.SourceGenerator.Logging; + +partial class TelemetrySourceGeneratorLoggingGen2Tests +{ + [Test] + public async Task Generate_GivenGenericLogMethod_GeneratesAGenericImplementation( + CancellationToken cancellationToken + ) + { + // Arrange: LoggerMessage.Define cannot carry an open generic, so this must use the state path. + const string source = """ + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void Log(T value); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1005"); + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(generated) + .Contains("void Log(T value)") + .Because("the generated method mirrors the interface's type parameter"); + await Assert + .That(generated) + .DoesNotContain("LoggerMessage.Define") + .Because("an open generic cannot be carried by LoggerMessage.Define"); + } + + [Test] + public async Task Generate_GivenGenericLogMethodWithConstraints_MirrorsTheConstraints( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogStruct(T value) where T : struct; + + void LogClass(T value) where T : class, IDisposable, new(); + + void LogUnmanaged(T value) where T : unmanaged; + + void LogNotNull(T value) where T : notnull; + + void LogBase(T value) where T : System.IComparable; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert.That(generated).Contains("where T : struct"); + await Assert.That(generated).Contains("where T : class, global::System.IDisposable, new()"); + await Assert.That(generated).Contains("where T : unmanaged"); + await Assert.That(generated).Contains("where T : notnull"); + await Assert.That(generated).Contains("where T : global::System.IComparable"); + } + + [Test] + public async Task Generate_GivenGenericLogMethodWithMultipleTypeParameters_MirrorsAll( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void Log(TKey key, TValue value); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("TestLoggerCore.Logging.g.cs")) + .Contains("void Log(TKey key, TValue value)"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs new file mode 100644 index 00000000..5394e899 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.LogPropertiesTransitive.cs @@ -0,0 +1,259 @@ +using System.Globalization; +using System.Text; + +namespace Purview.Telemetry.SourceGenerator.Logging; + +partial class TelemetrySourceGeneratorLoggingGen2Tests +{ + const string TransitiveLoggerSource = """ + #pragma warning disable EXTEXP0003 + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather([LogProperties(Transitive = true)]WeatherForecast weather); + } + + public class WeatherForecast + { + public int TemperatureC { get; set; } + public Location Location { get; set; } + public Coordinates Coordinates { get; set; } + } + + public class Location + { + public string City { get; set; } + public string Country { get; set; } + } + + public struct Coordinates + { + public double Lat { get; set; } + public double Lng { get; set; } + } + """; + + [Test] + public async Task Generate_GivenTransitiveLogProperty_FlattensNestedObject(CancellationToken cancellationToken) + { + // Arrange / Act + using var generationResult = await GenerateAsync(TransitiveLoggerSource, cancellationToken: cancellationToken); + + // Assert + var source = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(source) + .Contains("weather?.Location?.City") + .Because("a nested reference-typed property is reached through a second null-conditional access"); + await Assert + .That(source) + .Contains("\"weather.Location.City\"") + .Because("a transitive tag is named after the whole dotted path beneath the parameter"); + await Assert + .That(source) + .Contains("\"weather.TemperatureC\"") + .Because("the parameter's own properties are still flattened once"); + } + + [Test] + public async Task Generate_GivenTransitiveLogPropertyWithStructProperty_DoesNotEmitConditionalAccess( + CancellationToken cancellationToken + ) + { + // Arrange / Act + using var generationResult = await GenerateAsync(TransitiveLoggerSource, cancellationToken: cancellationToken); + + // Assert: `weather?.Coordinates?.Lat` does not compile - a non-nullable struct cannot take a `?`. + var source = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(source) + .Contains("weather?.Coordinates.Lat") + .Because("the conditional access belongs to the hop into the struct, not out of it"); + await Assert + .That(source) + .DoesNotContain("weather?.Coordinates?.Lat") + .Because("a null-conditional access cannot be applied to a non-nullable value type"); + } + + [Test] + public async Task Generate_GivenTransitiveLogProperty_AppliesSkipNullPropertiesAtEveryLevel( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + #pragma warning disable EXTEXP0003 + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather( + [LogProperties(Transitive = true, SkipNullProperties = true)]WeatherForecast weather + ); + } + + public class WeatherForecast + { + public Location Location { get; set; } + } + + public class Location + { + public string City { get; set; } + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert: the nested nullable leaf is guarded, not written unconditionally. + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(generated) + .Contains("weather?.Location?.City") + .Because("the nested property is still read through the chain"); + await Assert + .That(generated) + .Contains("!= null") + .Because("the nested nullable value is guarded by SkipNullProperties"); + } + + [Test] + public async Task Generate_GivenTransitiveLogPropertyWithIgnoredNestedProperty_OmitsIt( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + #pragma warning disable EXTEXP0003 + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather([LogProperties(Transitive = true)]WeatherForecast weather); + } + + public class WeatherForecast + { + public Location Location { get; set; } + } + + public class Location + { + public string City { get; set; } + + [LogPropertyIgnore] + public string Secret { get; set; } + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(generated) + .Contains("\"weather.Location.City\"") + .Because("a nested property without the ignore attribute is flattened"); + await Assert + .That(generated) + .DoesNotContain("Secret") + .Because("LogPropertyIgnore applies at every level, not only to the parameter's own properties"); + } + + [Test] + public async Task Generate_GivenSelfReferencingLogPropertyType_StopsAtTheCycle(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + #pragma warning disable EXTEXP0003 + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogNode([LogProperties(Transitive = true)]Node node); + } + + public class Node + { + public string Name { get; set; } + public Node Next { get; set; } + } + """; + + // Act: a cycle that recursed would never terminate. + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert.That(generated).Contains("\"node.Name\"").Because("the first level is flattened"); + await Assert.That(generated).Contains("\"node.Next\"").Because("the self-reference is logged as one tag"); + await Assert + .That(generated) + .DoesNotContain("\"node.Next.Next\"") + .Because("the repeated type is not walked again"); + } + + [Test] + public async Task Generate_GivenDeeplyNestedLogPropertyType_StopsAtTheDepthLimit( + CancellationToken cancellationToken + ) + { + // Arrange + const int depth = 40; + StringBuilder builder = new(); + builder.AppendLine("#pragma warning disable EXTEXP0003"); + builder.AppendLine("using Microsoft.Extensions.Logging;"); + builder.AppendLine("namespace Testing;"); + builder.AppendLine("[Logger]"); + builder.AppendLine("public interface ITestLogger"); + builder.AppendLine("{"); + builder.AppendLine(" void LogDeep([LogProperties(Transitive = true)]Level0 value);"); + builder.AppendLine("}"); + + for (var i = 0; i < depth; i++) + { + builder.AppendLine( + string.Format( + CultureInfo.InvariantCulture, + "public class Level{0} {{ public Level{1} Next {{ get; set; }} }}", + i, + i + 1 + ) + ); + } + + builder.AppendLine( + string.Format( + CultureInfo.InvariantCulture, + "public class Level{0} {{ public int Terminal {{ get; set; }} }}", + depth + ) + ); + + // Act: 40 levels is deeper than MaxTransitiveDepth, which must bound the walk rather than overflow. + using var generationResult = await GenerateAsync(builder.ToString(), cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert.That(generated).Contains("\"value.Next.Next\"").Because("the walk continues past the first level"); + await Assert + .That(generated) + .DoesNotContain("Terminal") + .Because("the chain stops at the depth limit instead of reaching the bottom of the graph"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs new file mode 100644 index 00000000..ef5cd443 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.TagProvider.cs @@ -0,0 +1,281 @@ +namespace Purview.Telemetry.SourceGenerator.Logging; + +partial class TelemetrySourceGeneratorLoggingGen2Tests +{ + const string TagProviderLoggerSource = """ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather([TagProvider(typeof(WeatherTagProvider), "GetTags")]WeatherForecast weather); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; + } + """; + + [Test] + public async Task Generate_GivenTagProvider_EmitsProviderCallIntoLogState(CancellationToken cancellationToken) + { + // Arrange / Act + using var generationResult = await GenerateAsync(TagProviderLoggerSource, cancellationToken: cancellationToken); + + // Assert + var source = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(source) + .Contains("global::Testing.WeatherTagProvider.GetTags") + .Because("the resolved provider method is called with the annotated parameter"); + await Assert + .That(source) + .Contains("\"weather.\" + tag.Key") + .Because("each contributed key is prefixed with the parameter name by default"); + await Assert.That(source).Contains(".AddTag(").Because("the pairs are added to the log state as extra tags"); + } + + [Test] + public async Task Generate_GivenTagProviderWithOmitReferenceName_EmitsUnprefixedKeys( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather( + [TagProvider(typeof(WeatherTagProvider), "GetTags", OmitReferenceName = true)]WeatherForecast weather + ); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(generated) + .Contains("state.AddTag(tag.Key, tag.Value)") + .Because("OmitReferenceName leaves the contributed key untouched"); + await Assert + .That(generated) + .DoesNotContain("\"weather.\" + tag.Key") + .Because("the parameter name is not prefixed onto a provider key"); + } + + [Test] + public async Task Generate_GivenTagProviderWithStringValues_AcceptsThePairs(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather([TagProvider(typeof(WeatherTagProvider), "GetTags")]WeatherForecast weather); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).HasNoDiagnostics(); + await Assert + .That(generationResult.GetSource("TestLoggerCore.Logging.g.cs")) + .Contains("global::Testing.WeatherTagProvider.GetTags") + .Because("the string-valued pair shape the .NET generator also accepts is usable"); + } + + [Test] + public async Task Generate_GivenTagProviderOnV1Method_UpgradesToTheStatePath(CancellationToken cancellationToken) + { + // Arrange: LoggerMessage.Define carries no tag bag, so a forced v1 cannot honour a provider. + const string source = """ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger(GenerationMode = LoggerGenerationMode.V1)] + public interface ITestLogger + { + void LogWeather([TagProvider(typeof(WeatherTagProvider), "GetTags")]WeatherForecast weather); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestLoggerCore.Logging.g.cs"); + await Assert + .That(generated) + .Contains("global::Testing.WeatherTagProvider.GetTags") + .Because("the provider is honoured even when v1 was requested"); + await Assert + .That(generated) + .DoesNotContain("LoggerMessage.Define") + .Because("v1 cannot carry the provider's tags, so the state path is used instead"); + } + + [Test] + public async Task Generate_GivenTagProviderWithUnknownMethod_RaisesTSG2025(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather([TagProvider(typeof(WeatherTagProvider), "DoesNotExist")]WeatherForecast weather); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).HasDiagnostic("TSG2025"); + } + + [Test] + public async Task Generate_GivenTagProviderWithWrongReturnType_RaisesTSG2026(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Microsoft.Extensions.Logging; + + namespace Testing; + + [Logger] + public interface ITestLogger + { + void LogWeather([TagProvider(typeof(WeatherTagProvider), "GetTags")]WeatherForecast weather); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static void GetTags(WeatherForecast weather) { } + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).HasDiagnostic("TSG2026"); + } + + [Test] + public async Task Generate_GivenTagProviderOnActivityMethod_RaisesTSG2027(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using System.Collections.Generic; + using System.Diagnostics; + using Microsoft.Extensions.Logging; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities + { + [Activity] + Activity? Start([TagProvider(typeof(WeatherTagProvider), "GetTags")]WeatherForecast weather); + } + + public class WeatherForecast + { + public string Summary { get; set; } + } + + public static class WeatherTagProvider + { + public static IEnumerable> GetTags(WeatherForecast weather) => + new[] { new KeyValuePair("Summary", weather.Summary) }; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).HasDiagnostic("TSG2027"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.cs index c6e8db27..1c6a81c1 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingGen2Tests.cs @@ -39,7 +39,7 @@ await Assert [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenInterfaceWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenInterfaceWithGenerics_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -65,12 +65,12 @@ public interface ITestLogger<{genericTypeDef}> {{ ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenMethodWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenGenericInterfaceWithManyTypeParameters_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -96,7 +96,7 @@ public interface ITestLogger<{genericTypeDef}> {{ ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } [Test] diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingTests.cs index 3917ce18..21f18ae4 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorLoggingTests.cs @@ -37,7 +37,7 @@ await Assert [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenInterfaceWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenInterfaceWithGenerics_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -63,12 +63,12 @@ public interface ITestLogger<{genericTypeDef}> {{ ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenMethodWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenGenericInterfaceWithManyTypeParameters_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -94,6 +94,6 @@ public interface ITestLogger<{genericTypeDef}> {{ ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } } diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GenericInterfaces.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GenericInterfaces.cs new file mode 100644 index 00000000..4f973059 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.GenericInterfaces.cs @@ -0,0 +1,37 @@ +namespace Purview.Telemetry.SourceGenerator.Metrics; + +partial class TelemetrySourceGeneratorMetricsTests +{ + [Test] + public async Task Generate_GivenGenericMeterInterfaceWithGenericMeasurement_GeneratesAGenericClass( + CancellationToken cancellationToken + ) + { + // Arrange: the measurement is an interface type parameter, so the instrument field can name it. + const string source = """ + namespace Testing; + + [Meter("testing-meter")] + public interface ITestMetrics where T : struct + { + [Counter] + void Record(T value); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); + var generated = generationResult.GetSource("Testing.TestMetricsCore.Metric.g.cs"); + await Assert + .That(generated) + .Contains("class TestMetricsCore") + .Because("the generated class is generic over the interface's type parameters"); + await Assert + .That(generated) + .Contains("Counter") + .Because("the instrument's measurement type is the interface's type parameter"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.Generics.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.Generics.cs new file mode 100644 index 00000000..e130ce57 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.Generics.cs @@ -0,0 +1,64 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Metrics; + +partial class TelemetrySourceGeneratorMetricsTests +{ + [Test] + public async Task Generate_GivenGenericMetricMethodWithConcreteMeasurement_MirrorsTheTypeParameter( + CancellationToken cancellationToken + ) + { + // Arrange: the measurement is concrete, so only the tag parameter is generic. + const string source = """ + namespace Testing; + + [Meter] + public interface ITestMetrics + { + [Counter] + void Record(int value, T tag); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1005"); + await Assert + .That(generationResult.GetSource("Testing.TestMetricsCore.Metric.g.cs")) + .Contains("void Record(int value, T tag)") + .Because("the generated instrument method mirrors the interface's type parameter"); + } + + [Test] + public async Task Generate_GivenGenericMeasurementTypeParameter_RaisesTSG1005(CancellationToken cancellationToken) + { + // Arrange: an instrument is a field, and a field cannot name a method's type parameter. + const string source = """ + namespace Testing; + + [Meter] + public interface ITestMetrics + { + [Counter] + void Record(T value) where T : struct; + } + """; + + // Act + using var generationResult = await GenerateAsync( + source, + TelemetrySourceGeneratorTestOptions.NoValidation, + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(generationResult).HasDiagnostic("TSG1005"); + await Assert + .That(generationResult.GetSource("Testing.TestMetricsCore.Metric.g.cs")) + .Contains("throw new global::System.NotSupportedException()") + .Because("the method is emitted as a stub so the interface stays implemented"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.cs index a8c78c96..0f6df9c2 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.cs @@ -90,7 +90,7 @@ await Assert [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenInterfaceWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenInterfaceWithGenerics_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -118,12 +118,12 @@ public interface ITestMetrics<{{genericTypeDef}}> { ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } [Test] [MethodDataSource(nameof(TelemetrySourceGeneratorTests.GetGenericTypeDefCount))] - public async Task Generate_GivenMethodWithGenerics_RaisesDiagnostics( + public async Task Generate_GivenGenericInterfaceWithManyTypeParameters_GeneratesAGenericClass( int genericTypeCount, CancellationToken cancellationToken ) @@ -151,6 +151,6 @@ public interface ITestMetrics<{{genericTypeDef}}> { ); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG1004"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG1004"); } } diff --git a/src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersActivityCapabilityTests.cs b/src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersActivityCapabilityTests.cs new file mode 100644 index 00000000..a52adb41 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersActivityCapabilityTests.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Proves the Activity.AddException capability probe on both sides: a compilation that references +/// System.Diagnostics.DiagnosticSource resolves the method, and one that does not reports it unavailable so the +/// emitter keeps its hand-built event. +/// +public class SharedHelpersActivityCapabilityTests +{ + [Test] + public async Task IsActivityAddExceptionAvailable_GivenNoDiagnosticSource_ReturnsFalse() + { + // Arrange: an empty reference set cannot resolve System.Diagnostics.Activity at all. + var compilation = CSharpCompilation.Create( + "ProbeAssembly", + [SyntaxFactory.ParseSyntaxTree("internal sealed class Probe { }")], + [], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Act + var available = SharedHelpers.IsActivityAddExceptionAvailable(compilation); + + // Assert + await Assert.That(available).IsFalse(); + } + + [Test] + public async Task IsActivityAddExceptionAvailable_GivenDiagnosticSource_ReturnsTrue() + { + // Arrange + var compilation = CSharpCompilation.Create( + "ProbeAssembly", + [SyntaxFactory.ParseSyntaxTree("internal sealed class Probe { }")], + [MetadataReference.CreateFromFile(typeof(System.Diagnostics.Activity).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Act + var available = SharedHelpers.IsActivityAddExceptionAvailable(compilation); + + // Assert + await Assert.That(available).IsTrue(); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersMeterFactoryCapabilityTests.cs b/src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersMeterFactoryCapabilityTests.cs new file mode 100644 index 00000000..6075fd2e --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Helpers/SharedHelpersMeterFactoryCapabilityTests.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Proves the IMeterFactory capability probe on both sides. The probe exists precisely so the generated +/// constructor follows the referenced packages rather than the target framework: a .NET Framework project that +/// references Microsoft.Extensions.Diagnostics.Abstractions has the type, and a modern one pinned to an older line +/// may not. These tests assert the type is what decides, which a define could not. +/// +public class SharedHelpersMeterFactoryCapabilityTests +{ + [Test] + public async Task IsMeterFactoryAvailable_GivenNoDiagnosticsAbstractions_ReturnsFalse() + { + // Arrange: an empty reference set resolves neither IMeterFactory nor System.Diagnostics.Metrics. + var compilation = CSharpCompilation.Create( + "ProbeAssembly", + [SyntaxFactory.ParseSyntaxTree("internal sealed class Probe { }")], + [], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Act + var available = SharedHelpers.IsMeterFactoryAvailable(compilation); + + // Assert + await Assert.That(available).IsFalse(); + } + + [Test] + public async Task IsMeterFactoryAvailable_GivenDiagnosticsAbstractions_ReturnsTrue() + { + // Arrange + var compilation = CSharpCompilation.Create( + "ProbeAssembly", + [SyntaxFactory.ParseSyntaxTree("internal sealed class Probe { }")], + [MetadataReference.CreateFromFile(typeof(System.Diagnostics.Metrics.IMeterFactory).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Act + var available = SharedHelpers.IsMeterFactoryAvailable(compilation); + + // Assert + await Assert.That(available).IsTrue(); + } +} From 9a022bfeea77379f661e7d902db1b1af13753335 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 16:02:06 +0000 Subject: [PATCH 3/8] perf(benchmarks): consume both sides of a comparison through the interface MetricsBenchmarks and LoggerBenchmarks held the generated implementation as its interface and the hand-written baseline as its concrete type. At nanosecond scale that is not a fair pair: a concrete call is inlined into the measurement loop and an interface call is not, so the delta was dispatch reported as generator overhead. Every generated metrics row came out at a flat 1.3 ns whatever the instrument or tag count, and every disabled logging row at 1.13 ns against a 0.77 ns baseline. ManualMetricsTelemetry and ManualLoggerMessageTelemetry now implement the benchmark interfaces, the benchmark classes hold them as the interface, and the factories return them that way. The generated and hand-written rows then track each other: 1.29-1.31 ns against 1.28-1.34 ns for instruments on .NET 10.0, 1.11-1.13 ns for a disabled log call, within 3% on .NET Framework. The developer skill records the rule, so a new family does not reintroduce the asymmetry. --- .agents/skills/purview-telemetry-benchmarks/SKILL.md | 12 ++++++++++++ .../Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs | 9 ++++++--- .../Benchmarks/LoggerBenchmarks.cs | 6 ++++-- .../Benchmarks/MetricsBenchmarks.cs | 11 ++++++++--- .../Manual/ManualLoggerMessageTelemetry.cs | 11 ++++++++++- .../Manual/ManualMetricsTelemetry.cs | 10 +++++++++- benchmarks/README.md | 8 ++++++++ 7 files changed, 57 insertions(+), 10 deletions(-) diff --git a/.agents/skills/purview-telemetry-benchmarks/SKILL.md b/.agents/skills/purview-telemetry-benchmarks/SKILL.md index 0521d502..8614abd4 100644 --- a/.agents/skills/purview-telemetry-benchmarks/SKILL.md +++ b/.agents/skills/purview-telemetry-benchmarks/SKILL.md @@ -203,6 +203,9 @@ Rules of thumb: the same; state the comparison basis if you do. - Every ratio you quote must exist in the report. If you want to say "~7%", the rows must say `1.07`. +- A generated-vs-manual gap that is *flat* across scenarios the method does different amounts of work in — the + same mean for a 0-tag counter and a 6-tag histogram, say — is a harness shape problem before it is a + generator problem. Check how each side is held (interface or concrete) before writing an overhead claim. - Keep the consumer's frame: absolute nanoseconds for the hot path, allocations per call, and whether the generated code is on par with "what you would write by hand". @@ -229,6 +232,12 @@ the `HasListener = false` row as the production cost, and describing a family th - A `` that says what question the benchmark answers and why the pair is fair. This is where the mechanism the analysis will cite is recorded. - `[GlobalSetup]` / `[GlobalCleanup]` dispose meters, loggers and listeners. + - **Both sides of a comparison must be consumed through the same shape.** Hold the generated *and* the + hand-written implementation as the same interface (that is why `ManualMetricsTelemetry` and + `ManualLoggerMessageTelemetry` implement the benchmark interfaces). At nanosecond scale a concrete call is + inlined into the benchmark loop and an interface call is not, so a generated-through-interface vs + hand-written-through-concrete pair measures dispatch and reports it as generator overhead — a flat per-call + delta that ignores the work the method does. 5. Register it: `benchmarks/README.md` (family table + project tree), `docs/wiki/Performance.md` (new section with one row per runtime), and `README.md` only if it is a consumer hot path. 6. Validate as described below, then run the full suite - a new family cannot be documented from a @@ -269,6 +278,9 @@ characteristic and fails BenchmarkDotNet's `GetSupportedBenchmarks` validation i - `just build` is green while `benchmarks/` does not compile - the project is not in the solution. - `--filter "*MultiTarget*"` matches two classes; the report then covers only part of what the docs claim. +- A comparison whose generated side is held as an interface and whose hand-written side is held as its concrete + type reports interface dispatch as generated overhead. `MetricsBenchmarks` and `LoggerBenchmarks` implement the + interface on the manual baseline to avoid it; a new family must do the same. - `net48` silently disappears on a non-Windows machine, and with it the `netstandard2.0` comparison. - A missing .NET 10 or .NET 11 runtime turns a leg into `NA` rows rather than a failure. - `BenchmarkDotNet.Artifacts/` is gitignored, so a "raw results" link to it on GitHub is a dead link. diff --git a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs index 1f03a236..506e62c4 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs @@ -70,11 +70,12 @@ public static (IMetricsFewTagsTelemetry FewTags, IMetricsManyTagsTelemetry ManyT /// /// Creates the source-generator-produced implementation /// alongside the hand-written , both backed by the same - /// for a fair comparison. + /// for a fair comparison and both returned as the interface, so the two + /// sides are consumed through the same call shape. /// public static ( IMetricsBenchmarkTelemetry Generated, - ManualMetricsTelemetry Manual + IMetricsBenchmarkTelemetry Manual ) CreateMetricsBenchmarkTelemetry() { var meterFactory = new SimpleMeterFactory(); @@ -87,6 +88,8 @@ ManualMetricsTelemetry Manual /// Creates the v2 (state-based ThreadLocalState) and v1 (LoggerMessage.Define) generated logger /// implementations alongside the hand-written baseline, /// all backed by either an always-enabled no-op logger or (always disabled). + /// The hand-written baseline is returned as an interface so both sides are consumed through the same call + /// shape; see . /// /// /// When true, a no-op logger with always returning @@ -101,7 +104,7 @@ ManualMetricsTelemetry Manual public static ( ILoggerOnlyTelemetry GeneratedV2, ILoggerV1OnlyTelemetry GeneratedV1, - ManualLoggerMessageTelemetry Manual + ILoggerOnlyTelemetry Manual ) CreateLoggerTelemetry(bool loggingEnabled) { ILogger loggerV2 = loggingEnabled diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs index 5b039c60..99318822 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/LoggerBenchmarks.cs @@ -1,6 +1,5 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; -using Purview.Telemetry.Benchmarks.Manual; using Purview.Telemetry.Benchmarks.Telemetry; namespace Purview.Telemetry.Benchmarks.Benchmarks; @@ -31,7 +30,10 @@ public class LoggerBenchmarks { ILoggerOnlyTelemetry _generatedV2 = default!; ILoggerV1OnlyTelemetry _generatedV1 = default!; - ManualLoggerMessageTelemetry _manual = default!; + + // Held as the interface, not as ManualLoggerMessageTelemetry: both implementations are then consumed through + // the same call shape, so a difference is generated code rather than an inlined concrete call on one side. + ILoggerOnlyTelemetry _manual = default!; /// /// When true, an always-enabled no-op logger is used so the full logging code path diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs index cec51f58..e98e2db9 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Benchmarks/MetricsBenchmarks.cs @@ -1,6 +1,5 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; -using Purview.Telemetry.Benchmarks.Manual; using Purview.Telemetry.Benchmarks.Telemetry; namespace Purview.Telemetry.Benchmarks.Benchmarks; @@ -26,7 +25,10 @@ namespace Purview.Telemetry.Benchmarks.Benchmarks; public class MetricsBenchmarks { IMetricsBenchmarkTelemetry _generated = default!; - ManualMetricsTelemetry _manual = default!; + + // Held as the interface, not as ManualMetricsTelemetry: both implementations are then consumed through the + // same call shape, so a difference is generated code rather than an inlined concrete call on one side. + IMetricsBenchmarkTelemetry _manual = default!; [GlobalSetup] public void Setup() @@ -37,7 +39,10 @@ public void Setup() [GlobalCleanup] public void Cleanup() { - _manual.Dispose(); + // The interface does not expose disposal, so the meter the hand-written baseline owns is released through + // the concrete type it still is. + if (_manual is IDisposable disposable) + disposable.Dispose(); } // --- AutoCounter: 0 tags --- diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualLoggerMessageTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualLoggerMessageTelemetry.cs index 0109841b..52dacc5e 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualLoggerMessageTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualLoggerMessageTelemetry.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Purview.Telemetry.Benchmarks.Telemetry; namespace Purview.Telemetry.Benchmarks.Manual; @@ -9,7 +10,15 @@ namespace Purview.Telemetry.Benchmarks.Manual; /// This baseline demonstrates the ceiling performance of the pattern and verifies /// that the source generator adds no measurable overhead over a hand-written equivalent. /// -public sealed class ManualLoggerMessageTelemetry +/// +/// Implements so the benchmark can hold both the generated and the +/// hand-written implementation as the interface: a generated class is resolved from DI as its interface, and a +/// call that crosses an interface boundary cannot be inlined into the caller. The interface is the v2 one +/// because it declares the same four methods; the body here is the classic LoggerMessage.Define pattern, +/// which is what it is meant to represent. Holding this baseline as its concrete type instead would let the JIT +/// inline every body and would attribute the difference to the generator. +/// +public sealed class ManualLoggerMessageTelemetry : ILoggerOnlyTelemetry { static readonly Action _operationStartedAction = LoggerMessage.Define< string, diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs index 936bb7d9..3b588790 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs @@ -1,4 +1,5 @@ using System.Diagnostics.Metrics; +using Purview.Telemetry.Benchmarks.Telemetry; namespace Purview.Telemetry.Benchmarks.Manual; @@ -8,7 +9,14 @@ namespace Purview.Telemetry.Benchmarks.Manual; /// ) with the same instrument types, names, and tag patterns that the /// source generator produces, enabling a fair generated-vs-manual comparison. /// -public sealed class ManualMetricsTelemetry : IDisposable +/// +/// Implements so the benchmark can hold both the generated and the +/// hand-written implementation as the interface and compare like with like: a generated class is resolved from +/// DI as its interface, and a call that crosses an interface boundary cannot be inlined into the caller. Holding +/// this baseline as its concrete type instead would let the JIT inline every one-line body and would attribute +/// the difference to the generator. +/// +public sealed class ManualMetricsTelemetry : IMetricsBenchmarkTelemetry, IDisposable { readonly Meter _meter; diff --git a/benchmarks/README.md b/benchmarks/README.md index 7d4eb979..2197805d 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -119,6 +119,10 @@ Compares three logging implementations with a parameterised `HasLogging` flag: - `HasLogging = true` — an always-enabled no-op logger is used; the full code path (including `IsEnabled` check and message formatting) is exercised. - `HasLogging = false` — `NullLogger` is used; the `IsEnabled` guard short-circuits immediately. +All three are held as an interface in the benchmark — `ManualLoggerMessageTelemetry` implements `ILoggerOnlyTelemetry` +for that reason — so one side is not inlined into the measurement loop while the other is. Consuming the generated +class through its interface is also what a DI-resolved consumer gets. + ### `LoggerMultiTargetBenchmarks` Compares the overhead of including logging in multi-target telemetry with a parameterised `HasListener` flag (logging is always enabled): @@ -163,6 +167,10 @@ benchmark with the manual implementation as the baseline: | Histogram, 0 tags | `RecordDuration(42.0)` | `Histogram.Record(42.0)` | | Histogram, 1 tag | `RecordDurationByEndpoint(42.0, "/api/data")` | `Histogram.Record(42.0, KeyValuePair("endpoint", "/api/data"))` | +Both implementations are held as `IMetricsBenchmarkTelemetry` — `ManualMetricsTelemetry` implements the interface for +that reason — so neither side is inlined into the measurement loop and a difference is generated code rather than call +shape. + Observable instruments are absent for the reason given above: a callback registered once has no per-operation hot path. ## Project Structure From cd003a8fd98f99fac07d5fdb51a1568e35d2961e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 16:02:20 +0000 Subject: [PATCH 4/8] docs: refresh the performance tables from a fresh three-runtime run A full Release run of all seven benchmark classes on .NET 10, .NET 11 and .NET Framework 4.8 (198 benchmarks, no NA rows), plus a like-for-like re-run of MetricsBenchmarks and LoggerBenchmarks after their baselines were moved to the interface. docs/wiki/Performance.md carries the new environment header, a row per runtime and parameter in every family, a new "Generator model overhead" section for GeneratorModelBenchmarks, and rewritten interpretations. The stale caveats are gone: .NET 11 is measured, no .NET 8/9 rows are missing, and .NET Framework produced results. The raw-results link went with them, because BenchmarkDotNet.Artifacts is gitignored and the link to it never resolved. README.md keeps its .NET 10.0 digest and says the ratios compare against the hand-written implementation of the same scenario. The logging claims are the ones that moved: v1 is at parity rather than faster, and v2 is the fastest of the three on every runtime. The metrics table no longer implies generated code is 1.7x slower, because it was not. Platform-Support.md now cites the measured model comparison instead of saying the tables predate it. AGENTS.md records the three benchmark target frameworks. --- AGENTS.md | 2 +- README.md | 36 ++-- docs/wiki/Performance.md | 346 +++++++++++++++++++++++++++------- docs/wiki/Platform-Support.md | 2 +- 4 files changed, 295 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f46aea4..66240220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ samples/ └── SampleApp.Net48.slnx benchmarks/ -└── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net10.0) +└── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net10.0/net11.0) .build/ # TS scripts (build-pack.ts, update-version.ts) .agents/skills/ # Installed + developer skills, committed (see "Installed skills") diff --git a/README.md b/README.md index 7eb81e71..0ba8a2ad 100644 --- a/README.md +++ b/README.md @@ -203,47 +203,47 @@ The [.NET Aspire Sample](https://github.com/purview-dev/telemetry-sourcegenerato ## Performance -Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.401. See the [Performance](https://purview.dev/docs/telemetry-sourcegenerator/performance/) page for full cross-runtime results. +Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 11.0.100. See the [Performance](https://purview.dev/docs/telemetry-sourcegenerator/performance/) page for the full cross-runtime results (.NET 10, .NET 11 and .NET Framework 4.8). Ratios below compare generated against the hand-written implementation of the same scenario. ### Activities (.NET 10.0) | Scenario | HasListener | Manual | Generated | Ratio | | --- | --- | --- | --- | --- | -| start + complete | False | 0.53 ns | 0.58 ns | 1.11x | -| start + complete | True | 220 ns / 1008 B | 217 ns / 1008 B | 0.98x | -| start + fail | True | 208 ns / 920 B | 203 ns / 920 B | 0.92x | +| start + complete | False | 1.50 ns | 1.51 ns | 1.01x | +| start + complete | True | 218 ns / 1008 B | 213 ns / 1008 B | 0.98x | +| start + fail | True | 192 ns / 920 B | 191 ns / 920 B | 0.99x | -Generated activities match hand-written code with identical allocations. +Generated activities match hand-written code with identical allocations. With a listener active, ~99% of the measured time is the framework creating the `Activity`, not generated code. ### Logging (.NET 10.0) | Scenario | HasLogging | LoggerMessage.Define | Generated v1 | Generated v2 | | --- | --- | --- | --- | --- | -| single Info call | True | 6.12 ns | 4.43 ns (0.72x) | 3.80 ns (0.62x) | -| full lifecycle (4 calls) | True | 18.88 ns | 19.58 ns (1.04x) | 15.01 ns (0.80x) | +| single Info call | True | 5.36 ns | 5.36 ns (1.00x) | 4.39 ns (0.82x) | +| full lifecycle (4 calls) | True | 20.21 ns | 19.60 ns (0.97x) | 17.96 ns (0.89x) | -Both v1 and v2 allocate **0 bytes** per call on all runtimes. Generated methods are emitted with `[MethodImpl(AggressiveInlining)]`; generated v1 and v2 both beat the manual `LoggerMessage.Define` baseline for single calls on .NET 10.0. +Both v1 and v2 allocate **0 bytes** per call on all runtimes. Generated methods are emitted with `[MethodImpl(AggressiveInlining)]`; v2 (state-based) is the fastest implementation on .NET 10.0, .NET 11.0 and .NET Framework 4.8, and v1 — which reproduces the hand-written `LoggerMessage.Define` delegate — is at parity with the hand-written baseline (0.97-1.00x here, 0.99-1.03x across all runtimes). The one place v2 is measurably more expensive is the disabled level on .NET Framework 4.8 (8.19 ns against 2.82 ns), before any log state is built. ### Multi-Target (.NET 10.0, Activity + Logging + Metrics) | Scenario | HasListener | Single-target | Multi-target generated | Multi-target manual | |---|---|---|---|---| -| start + complete | True | 216 ns / 1008 B | 230 ns / 1032 B (1.07x) | 260 ns / 1032 B (1.20x) | +| start + complete | True | 204 ns / 1008 B | 213 ns / 1032 B (1.04x) | 237 ns / 1032 B (1.16x) | -Adding full Activity+Logging+Metrics multi-target generation costs ~7% over Activity-only on .NET 10.0, and the generated multi-target code is faster than the hand-written equivalent. +Adding full Activity+Logging+Metrics multi-target generation costs ~4% over Activity-only on .NET 10.0, and the generated multi-target code is faster than the hand-written equivalent. The 24 B difference is the log and metric state, which the hand-written version allocates identically. ### Metrics (.NET 10.0) | Scenario | Generated | Notes | | --- | --- | --- | -| auto-counter (0 tags) | 0.40 ns | - | -| auto-counter (1 tag) | 0.35 ns | - | -| up-down counter | 0.37 ns | - | -| histogram (0 tags) | 0.42 ns | - | -| histogram (1 tag) | 0.34 ns | - | -| 4+ tags (TagList) | 5-8 ns | Stack-allocated `TagList` | - -All instruments are **0 allocations** on all runtimes. +| auto-counter (0 tags) | 1.31 ns | - | +| auto-counter (1 tag) | 1.30 ns | - | +| up-down counter | 1.30 ns | - | +| histogram (0 tags) | 1.29 ns | - | +| histogram (1 tag) | 1.29 ns | - | +| 4+ tags (TagList) | 4.9-7.1 ns | Stack-allocated `TagList` | + +All instruments are **0 allocations** on all runtimes, generated and hand-written alike. On .NET 10.0 the generated rows sit within 2% of the hand-written equivalents (1.29-1.31 ns against 1.28-1.34 ns), with both consumed through the same interface, so the ~1.3 ns is the instrument update itself rather than generator overhead. ## v4 Breaking Changes diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index 4e457350..25897613 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -6,29 +6,42 @@ Full cross-runtime benchmark results for the Purview Telemetry Source Generator, ```bash dotnet run --project benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj \ - --configuration Release --framework net10.0 + --configuration Release --framework net11.0 ``` -Results are written to `BenchmarkDotNet.Artifacts/results/` as `*-report-github.md`, `*.csv`, and `*.html`. +`just benchmark` runs the same command. Every benchmark class declares one job per runtime +(`RuntimeMoniker.Net10_0`, `Net11_0`, `Net48`), so one invocation measures all three and the framework the suite is +launched with only selects the BenchmarkDotNet host. + +Results are written to `BenchmarkDotNet.Artifacts/results/` as `*-report-github.md`, `*.csv`, and `*.html`. That folder +is **gitignored**: the artifacts stay on the machine that produced them, and the numbers below are what is published. > [!IMPORTANT] -> BenchmarkDotNet numbers are only meaningful from **Release** builds. Run the suite fresh (`just benchmark-docs`) before relying on the tables below for a specific machine/SDK combination. +> BenchmarkDotNet numbers are only meaningful from **Release** builds. Run the suite fresh (`just benchmark-docs`) +> before relying on the tables below for a specific machine/SDK combination. ## Environment ``` -BenchmarkDotNet v0.15.8, Windows 11 (10.0.28020.2991) +BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26300.9974) 13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores -.NET SDK 10.0.401 - .NET 10.0 : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3 +Memory: 63.77 GB Total, 39.06 GB Available +.NET SDK 11.0.100 + [Host] : .NET 11.0.0 (11.0.0, 11.0.26.53018), X64 RyuJIT x86-64-v3 + Job-GVKUBM : .NET 10.0.15 (10.0.15, 10.0.1526.53008), X64 RyuJIT x86-64-v3 + Job-IHFIKV : .NET 11.0.0 (11.0.0, 11.0.26.53018), X64 RyuJIT x86-64-v3 + Job-UGENHH : .NET Framework 4.8 (4.8.10008.0), X64 RyuJIT VectorSize=256 ``` -> **Note:** these are the numbers from the run recorded above. The `.NET 8.0` and `.NET 9.0` jobs and their -> rows were removed when support for those frameworks was dropped, so the tables are a subset of that run, -> not a fresh one. Run `just benchmark-docs` before quoting them for a specific machine/SDK combination. -> `.NET 11.0` is not measured yet: BenchmarkDotNet 0.15.8 has no `RuntimeMoniker.Net11_0`. - -> **Note:** the .NET Framework 4.7/4.8 jobs were executed but produced no results in this run and are excluded from the tables below. +Every table below comes from this machine and SDK. `ActivityBenchmarks`, `MultiTargetVsSingleTargetBenchmarks`, +`LoggerMultiTargetBenchmarks`, `TagListBenchmarks` and `GeneratorModelBenchmarks` come from the full suite. +`MetricsBenchmarks` and `LoggerBenchmarks` were re-run afterwards, once their hand-written baselines were changed to +implement the benchmark interface so both sides of those comparisons are consumed through the same call shape; before +that they compared an interface-typed generated instance against a concrete-typed hand-written one, which reports +interface dispatch as generator overhead. A table never mixes runs. `Ratio` is measured +against the row marked in bold, which is the class's `Baseline = true` row. `Allocated` `-` means the path does not +allocate. Rows whose mean is a nanosecond or two carry run-to-run variation of a few tenths of a nanosecond; treat +ratios in that range as "indistinguishable" rather than as a ranking. Generated hot-path methods carry `[MethodImpl(MethodImplOptions.AggressiveInlining)]`, which lets the JIT inline the small generated telemetry methods into callers. @@ -40,33 +53,93 @@ Compares the source-generator-produced `ActivityOnlyTelemetryCore` against a han | Method | Runtime | HasListener | Mean | Ratio | Allocated | | --- | --- | --- | --- | --- | --- | -| Manual: start + complete | .NET 10.0 | False | 0.53 ns | 1.00 | - | -| Generated: start + complete | .NET 10.0 | False | 0.58 ns | 1.11 | - | -| Manual: start + fail | .NET 10.0 | False | 0.75 ns | 1.43 | - | -| Generated: start + fail | .NET 10.0 | False | 0.55 ns | 1.04 | - | -| **Manual: start + complete** | **.NET 10.0** | **True** | **220.37 ns** | **1.00** | **1008 B** | -| Generated: start + complete | .NET 10.0 | True | 216.69 ns | 0.98 | 1008 B | -| Manual: start + fail | .NET 10.0 | True | 207.55 ns | 0.94 | 920 B | -| Generated: start + fail | .NET 10.0 | True | 203.12 ns | 0.92 | 920 B | - -**Interpretation:** Generated activities match hand-written code within ~2% and allocate identically across all tested runtimes. When a listener is active the absolute times are dominated by the framework's Activity creation, not the generated code. +| **Manual: start + complete** | **.NET 10.0** | **False** | **1.504 ns** | **1.00** | **-** | +| Generated: start + complete | .NET 10.0 | False | 1.514 ns | 1.01 | - | +| Manual: start + fail | .NET 10.0 | False | 1.355 ns | 0.90 | - | +| Generated: start + fail | .NET 10.0 | False | 1.730 ns | 1.15 | - | +| **Manual: start + complete** | **.NET 11.0** | **False** | **1.340 ns** | **1.00** | **-** | +| Generated: start + complete | .NET 11.0 | False | 1.712 ns | 1.28 | - | +| Manual: start + fail | .NET 11.0 | False | 1.531 ns | 1.14 | - | +| Generated: start + fail | .NET 11.0 | False | 1.544 ns | 1.15 | - | +| **Manual: start + complete** | **.NET Framework 4.8** | **False** | **16.646 ns** | **1.00** | **-** | +| Generated: start + complete | .NET Framework 4.8 | False | 16.836 ns | 1.01 | - | +| Manual: start + fail | .NET Framework 4.8 | False | 16.117 ns | 0.97 | - | +| Generated: start + fail | .NET Framework 4.8 | False | 16.768 ns | 1.01 | - | +| **Manual: start + complete** | **.NET 10.0** | **True** | **218.245 ns** | **1.00** | **1008 B** | +| Generated: start + complete | .NET 10.0 | True | 212.817 ns | 0.98 | 1008 B | +| Manual: start + fail | .NET 10.0 | True | 192.314 ns | 0.88 | 920 B | +| Generated: start + fail | .NET 10.0 | True | 191.271 ns | 0.88 | 920 B | +| **Manual: start + complete** | **.NET 11.0** | **True** | **204.501 ns** | **1.00** | **1008 B** | +| Generated: start + complete | .NET 11.0 | True | 195.388 ns | 0.96 | 1008 B | +| Manual: start + fail | .NET 11.0 | True | 201.220 ns | 0.98 | 920 B | +| Generated: start + fail | .NET 11.0 | True | 193.368 ns | 0.95 | 920 B | +| **Manual: start + complete** | **.NET Framework 4.8** | **True** | **524.705 ns** | **1.00** | **1268 B** | +| Generated: start + complete | .NET Framework 4.8 | True | 520.174 ns | 0.99 | 1268 B | +| Manual: start + fail | .NET Framework 4.8 | True | 500.322 ns | 0.95 | 1179 B | +| Generated: start + fail | .NET Framework 4.8 | True | 498.376 ns | 0.95 | 1180 B | + +**Interpretation:** Generated activities are indistinguishable from hand-written ones on every runtime. With a listener +active — the production path — the generated rows land within 1–5% of the hand-written rows (0.95–0.99), and the absolute +time (190–525 ns) is dominated by the framework's own `Activity` creation and event recording, not by generated code. The +one-byte difference in the .NET Framework `start + fail` row (1179 B vs 1180 B) is allocation-size rounding of the same +event payload. With no listener both implementations are a single guard: ~1.3–1.7 ns on .NET 10/11, where a few tenths of +a nanosecond is run-to-run noise, and ~16–17 ns on .NET Framework. Nothing allocates on the no-listener path on any runtime. ## Logging **Source:** `LoggerBenchmarks` -Compares three logging approaches: hand-written `LoggerMessage.Define` (gold-standard manual), generated v1 (`LoggerMessage.Define` pattern), and generated v2 (state-based `ThreadLocalState` pattern). +Compares three logging approaches: hand-written `LoggerMessage.Define` (gold-standard manual), generated v1 (`LoggerMessage.Define` pattern), and generated v2 (state-based `ThreadLocalState` pattern). `HasLogging = true` uses an always-enabled no-op logger, so the full path — `IsEnabled` check, state population, `ILogger.Log` dispatch — is exercised; `false` uses `NullLogger`, so the guard short-circuits. | Method | Runtime | HasLogging | Mean | Ratio | Allocated | | --- | --- | --- | --- | --- | --- | -| **Manual: single Info call** | **.NET 10.0** | **True** | **6.12 ns** | **1.00** | **-** | -| Generated v1 — single Info call | .NET 10.0 | True | 4.43 ns | 0.72 | - | -| Generated v2 — single Info call | .NET 10.0 | True | 3.80 ns | 0.62 | - | -| Manual: full lifecycle (4 calls) | .NET 10.0 | True | 18.88 ns | 3.08 | - | -| Generated v1 — full lifecycle | .NET 10.0 | True | 19.58 ns | 3.20 | - | -| Generated v2 — full lifecycle | .NET 10.0 | True | 15.01 ns | 2.45 | - | - -**Interpretation:** Generated v1 and v2 both allocate **zero bytes** per call across all runtimes. On .NET 10.0 the generated variants are **faster** than the hand-written `LoggerMessage.Define` baseline for single calls (v1 0.72x, v2 0.62x) thanks to `[MethodImpl(AggressiveInlining)]`; v2 (state-based) is the fastest path on every runtime. The full-lifecycle cost is dominated by the four `IsEnabled` checks and message-formatting paths that all three implementations share. +| **Manual (LoggerMessage.Define) — single Info call** | **.NET 10.0** | **False** | **1.133 ns** | **1.00** | **-** | +| Generated v1 (LoggerMessage.Define) — single Info call | .NET 10.0 | False | 1.106 ns | 0.98 | - | +| Generated v2 (ThreadLocalState) — single Info call | .NET 10.0 | False | 1.114 ns | 0.98 | - | +| Manual (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 10.0 | False | 1.492 ns | 1.32 | - | +| Generated v1 (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 10.0 | False | 1.500 ns | 1.32 | - | +| Generated v2 (ThreadLocalState) — full lifecycle (4 calls) | .NET 10.0 | False | 1.374 ns | 1.21 | - | +| **Manual (LoggerMessage.Define) — single Info call** | **.NET 11.0** | **False** | **1.116 ns** | **1.00** | **-** | +| Generated v1 (LoggerMessage.Define) — single Info call | .NET 11.0 | False | 1.129 ns | 1.01 | - | +| Generated v2 (ThreadLocalState) — single Info call | .NET 11.0 | False | 1.116 ns | 1.00 | - | +| Manual (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 11.0 | False | 1.532 ns | 1.37 | - | +| Generated v1 (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 11.0 | False | 1.494 ns | 1.34 | - | +| Generated v2 (ThreadLocalState) — full lifecycle (4 calls) | .NET 11.0 | False | 1.398 ns | 1.25 | - | +| **Manual (LoggerMessage.Define) — single Info call** | **.NET Framework 4.8** | **False** | **2.819 ns** | **1.00** | **-** | +| Generated v1 (LoggerMessage.Define) — single Info call | .NET Framework 4.8 | False | 2.816 ns | 1.00 | - | +| Generated v2 (ThreadLocalState) — single Info call | .NET Framework 4.8 | False | 8.189 ns | 2.91 | - | +| Manual (LoggerMessage.Define) — full lifecycle (4 calls) | .NET Framework 4.8 | False | 11.380 ns | 4.04 | - | +| Generated v1 (LoggerMessage.Define) — full lifecycle (4 calls) | .NET Framework 4.8 | False | 11.396 ns | 4.04 | - | +| Generated v2 (ThreadLocalState) — full lifecycle (4 calls) | .NET Framework 4.8 | False | 18.976 ns | 6.73 | - | +| **Manual (LoggerMessage.Define) — single Info call** | **.NET 10.0** | **True** | **5.355 ns** | **1.00** | **-** | +| Generated v1 (LoggerMessage.Define) — single Info call | .NET 10.0 | True | 5.360 ns | 1.00 | - | +| Generated v2 (ThreadLocalState) — single Info call | .NET 10.0 | True | 4.391 ns | 0.82 | - | +| Manual (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 10.0 | True | 20.208 ns | 3.77 | - | +| Generated v1 (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 10.0 | True | 19.600 ns | 3.66 | - | +| Generated v2 (ThreadLocalState) — full lifecycle (4 calls) | .NET 10.0 | True | 17.964 ns | 3.35 | - | +| **Manual (LoggerMessage.Define) — single Info call** | **.NET 11.0** | **True** | **5.352 ns** | **1.00** | **-** | +| Generated v1 (LoggerMessage.Define) — single Info call | .NET 11.0 | True | 5.315 ns | 0.99 | - | +| Generated v2 (ThreadLocalState) — single Info call | .NET 11.0 | True | 4.416 ns | 0.83 | - | +| Manual (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 11.0 | True | 18.554 ns | 3.47 | - | +| Generated v1 (LoggerMessage.Define) — full lifecycle (4 calls) | .NET 11.0 | True | 19.103 ns | 3.57 | - | +| Generated v2 (ThreadLocalState) — full lifecycle (4 calls) | .NET 11.0 | True | 15.708 ns | 2.94 | - | +| **Manual (LoggerMessage.Define) — single Info call** | **.NET Framework 4.8** | **True** | **17.785 ns** | **1.00** | **-** | +| Generated v1 (LoggerMessage.Define) — single Info call | .NET Framework 4.8 | True | 18.235 ns | 1.03 | - | +| Generated v2 (ThreadLocalState) — single Info call | .NET Framework 4.8 | True | 14.652 ns | 0.82 | - | +| Manual (LoggerMessage.Define) — full lifecycle (4 calls) | .NET Framework 4.8 | True | 71.259 ns | 4.01 | - | +| Generated v1 (LoggerMessage.Define) — full lifecycle (4 calls) | .NET Framework 4.8 | True | 71.375 ns | 4.01 | - | +| Generated v2 (ThreadLocalState) — full lifecycle (4 calls) | .NET Framework 4.8 | True | 45.316 ns | 2.55 | - | + +**Interpretation:** Every row allocates **zero bytes**, on every runtime and on both paths. All three implementations are +consumed through `ILoggerOnlyTelemetry`, so neither side is inlined into the measurement loop. On the full path +(`HasLogging = true`) generated v2 is the fastest implementation on all three runtimes — 4.39 ns against 5.36 ns +hand-written on .NET 10.0 (0.82), 4.42 against 5.35 on .NET 11.0 (0.83), 14.65 against 17.79 on .NET Framework 4.8 +(0.82) — while generated v1, which reproduces the hand-written `LoggerMessage.Define` delegate, is at parity (0.99–1.03). +The four-call lifecycle costs v2 15.7–18.0 ns against 18.6–20.2 ns hand-written on the modern runtimes, and v1 sits +within 3% of the hand-written lifecycle. On the disabled path the `IsEnabled` guard dominates and the two delegate-based +implementations are indistinguishable (1.11–1.13 ns single call on .NET 10/11, 2.82 ns on .NET Framework). The one row +where v2's wiring is measurably more expensive is that disabled single call on .NET Framework 4.8: 8.19 ns against 2.82 ns +for the delegate baseline, 2.91× before any log state is built. ## Multi-target @@ -76,59 +149,185 @@ Measures the overhead of emitting Activity + Logging + Metrics from a single met | Method | Runtime | HasListener | Mean | Ratio | Allocated | Alloc Ratio | | --- | --- | --- | --- | --- | --- | --- | -| **Single-target (generated): start + complete** | **.NET 10.0** | **True** | **216.15 ns** | **1.00** | **1008 B** | **1.00** | -| Multi-target (generated): start + complete | .NET 10.0 | True | 230.41 ns | 1.07 | 1032 B | 1.02 | -| Multi-target (manual): start + complete | .NET 10.0 | True | 260.18 ns | 1.20 | 1032 B | 1.02 | -| Multi-target (generated): start + complete + record latency | .NET 10.0 | True | 229.65 ns | 1.06 | 1032 B | 1.02 | -| Multi-target (manual): start + complete + record latency | .NET 10.0 | True | 234.58 ns | 1.09 | 1032 B | 1.02 | - -**Interpretation:** When an Activity listener is active (production path), multi-target generation adds ~7% overhead over single-target Activity-only on .NET 10.0 — the real cost of the extra log call and metric increment, not generated-code overhead. The generated multi-target code is faster than the hand-written multi-target baseline (~1.07x vs ~1.20x of single-target). See the [Generated Output](Generated-Output.md) page for what the multi-target implementation looks like. +| **Single-target (generated): start + complete** | **.NET 10.0** | **False** | **1.716 ns** | **1.00** | **-** | **NA** | +| Multi-target (generated): start + complete | .NET 10.0 | False | 11.346 ns | 6.61 | 24 B | NA | +| Multi-target (manual): start + complete | .NET 10.0 | False | 14.384 ns | 8.38 | 24 B | NA | +| Multi-target (generated): start + complete + record latency | .NET 10.0 | False | 11.898 ns | 6.94 | 24 B | NA | +| Multi-target (manual): start + complete + record latency | .NET 10.0 | False | 13.162 ns | 7.67 | 24 B | NA | +| **Single-target (generated): start + complete** | **.NET 11.0** | **False** | **1.693 ns** | **1.00** | **-** | **NA** | +| Multi-target (generated): start + complete | .NET 11.0 | False | 11.704 ns | 6.92 | 24 B | NA | +| Multi-target (manual): start + complete | .NET 11.0 | False | 12.942 ns | 7.65 | 24 B | NA | +| Multi-target (generated): start + complete + record latency | .NET 11.0 | False | 10.806 ns | 6.39 | 24 B | NA | +| Multi-target (manual): start + complete + record latency | .NET 11.0 | False | 13.327 ns | 7.88 | 24 B | NA | +| **Single-target (generated): start + complete** | **.NET Framework 4.8** | **False** | **16.534 ns** | **1.00** | **-** | **NA** | +| Multi-target (generated): start + complete | .NET Framework 4.8 | False | 63.225 ns | 3.82 | 24 B | NA | +| Multi-target (manual): start + complete | .NET Framework 4.8 | False | 73.849 ns | 4.47 | 24 B | NA | +| Multi-target (generated): start + complete + record latency | .NET Framework 4.8 | False | 81.570 ns | 4.93 | 24 B | NA | +| Multi-target (manual): start + complete + record latency | .NET Framework 4.8 | False | 88.946 ns | 5.38 | 24 B | NA | +| **Single-target (generated): start + complete** | **.NET 10.0** | **True** | **204.268 ns** | **1.00** | **1008 B** | **1.00** | +| Multi-target (generated): start + complete | .NET 10.0 | True | 212.584 ns | 1.04 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 10.0 | True | 236.564 ns | 1.16 | 1032 B | 1.02 | +| Multi-target (generated): start + complete + record latency | .NET 10.0 | True | 213.345 ns | 1.04 | 1032 B | 1.02 | +| Multi-target (manual): start + complete + record latency | .NET 10.0 | True | 216.492 ns | 1.06 | 1032 B | 1.02 | +| **Single-target (generated): start + complete** | **.NET 11.0** | **True** | **209.172 ns** | **1.00** | **1008 B** | **1.00** | +| Multi-target (generated): start + complete | .NET 11.0 | True | 241.172 ns | 1.15 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 11.0 | True | 204.857 ns | 0.98 | 1032 B | 1.02 | +| Multi-target (generated): start + complete + record latency | .NET 11.0 | True | 231.273 ns | 1.11 | 1032 B | 1.02 | +| Multi-target (manual): start + complete + record latency | .NET 11.0 | True | 232.157 ns | 1.11 | 1032 B | 1.02 | +| **Single-target (generated): start + complete** | **.NET Framework 4.8** | **True** | **523.480 ns** | **1.00** | **1268 B** | **1.00** | +| Multi-target (generated): start + complete | .NET Framework 4.8 | True | 594.085 ns | 1.14 | 1292 B | 1.02 | +| Multi-target (manual): start + complete | .NET Framework 4.8 | True | 590.657 ns | 1.13 | 1284 B | 1.01 | +| Multi-target (generated): start + complete + record latency | .NET Framework 4.8 | True | 595.731 ns | 1.14 | 1284 B | 1.01 | +| Multi-target (manual): start + complete + record latency | .NET Framework 4.8 | True | 604.441 ns | 1.16 | 1292 B | 1.02 | + +**Interpretation:** With a listener active — the production path — multi-target generation adds 4% over Activity-only on +.NET 10.0 (1.04) and 11–15% on .NET 11.0 and .NET Framework 4.8, for the log call and metric increment it performs. On +.NET 10.0 the generated multi-target code is also faster than the hand-written multi-target baseline (212.6 vs 236.6 ns), +and on .NET Framework 4.8 the two are at parity (594.1 vs 590.7 ns). The hand-written multi-target baseline in this +family is still called on its concrete type, which favours it, so where the generated row wins it wins despite that. +On .NET 11.0 the generated row carries a ±18 ns +standard deviation in this run while the hand-written row carries ±2 ns, so the apparent gap there is not resolvable. +Without a listener the multi-target path costs 10.8–11.9 ns on the modern runtimes against 1.7 ns for the activity-only +guard, because a log guard and a metric increment run even when no activity is sampled; the hand-written equivalent is +slower still (12.9–14.4 ns). The 24 B per call on that path is the same on both implementations, and the listener-active +rows allocate 1032 B against 1008 B for activity-only — the extra 24 B belongs to the metric and log state, not to the +generated wrapper. See the [Generated Output](Generated-Output.md) page for what the multi-target implementation looks like. ## Logger multi-target **Source:** `LoggerMultiTargetBenchmarks` -Compares single-target logging-only vs. multi-target (Activity + Logging + Metrics) generated code with a hand-written multi-target baseline. - -| Method | Runtime | HasListener | Mean | Ratio | Allocated | -| --- | --- | --- | --- | --- | --- | -| **Multi-target (manual): start + complete** | **.NET 10.0** | **True** | **264.31 ns** | **1.00** | **1032 B** | -| Multi-target (generated v1): start + complete | .NET 10.0 | True | 236.44 ns | 0.89 | 1032 B | -| Multi-target (generated v2): start + complete | .NET 10.0 | True | 227.94 ns | 0.86 | 1032 B | -| Multi-target (manual): full lifecycle | .NET 10.0 | True | 243.84 ns | 0.92 | 1032 B | -| Multi-target (generated v1): full lifecycle | .NET 10.0 | True | 249.32 ns | 0.94 | 1032 B | -| Multi-target (generated v2): full lifecycle | .NET 10.0 | True | 216.86 ns | 0.82 | 1032 B | -| Single-target (generated v1): full lifecycle | .NET 10.0 | True | 18.21 ns | 0.07 | - | -| Single-target (generated v2): full lifecycle | .NET 10.0 | True | 15.69 ns | 0.06 | - | +Compares single-target logging-only against multi-target (Activity + Logging + Metrics) generated code with a hand-written multi-target baseline. Logging is always enabled; `HasListener` controls activity sampling. -**Interpretation:** Generated and manual multi-target implementations are within ~6% of each other on .NET 10.0, allocating identically (1032 B with a listener active). Logging-only accounts for a small fraction (~16-18 ns) of the multi-target cost (~230 ns); Activity creation dominates when a listener is active. +| Method | Runtime | HasListener | Mean | Ratio | Allocated | Alloc Ratio | +| --- | --- | --- | --- | --- | --- | --- | +| **Multi-target (manual): start + complete** | **.NET 10.0** | **False** | **12.91 ns** | **1.00** | **24 B** | **1.00** | +| Multi-target (generated v1): start + complete | .NET 10.0 | False | 13.18 ns | 1.02 | 24 B | 1.00 | +| Multi-target (generated v2): start + complete | .NET 10.0 | False | 11.10 ns | 0.86 | 24 B | 1.00 | +| Multi-target (manual): full lifecycle | .NET 10.0 | False | 13.60 ns | 1.05 | 24 B | 1.00 | +| Multi-target (generated v1): full lifecycle | .NET 10.0 | False | 13.53 ns | 1.05 | 24 B | 1.00 | +| Multi-target (generated v2): full lifecycle | .NET 10.0 | False | 12.84 ns | 0.99 | 24 B | 1.00 | +| Single-target (generated v1): full lifecycle | .NET 10.0 | False | 18.14 ns | 1.40 | - | 0.00 | +| Single-target (generated v2): full lifecycle | .NET 10.0 | False | 16.63 ns | 1.29 | - | 0.00 | +| **Multi-target (manual): start + complete** | **.NET 11.0** | **False** | **13.33 ns** | **1.00** | **24 B** | **1.00** | +| Multi-target (generated v1): start + complete | .NET 11.0 | False | 13.15 ns | 0.99 | 24 B | 1.00 | +| Multi-target (generated v2): start + complete | .NET 11.0 | False | 10.54 ns | 0.79 | 24 B | 1.00 | +| Multi-target (manual): full lifecycle | .NET 11.0 | False | 13.24 ns | 0.99 | 24 B | 1.00 | +| Multi-target (generated v1): full lifecycle | .NET 11.0 | False | 13.78 ns | 1.03 | 24 B | 1.00 | +| Multi-target (generated v2): full lifecycle | .NET 11.0 | False | 11.92 ns | 0.89 | 24 B | 1.00 | +| Single-target (generated v1): full lifecycle | .NET 11.0 | False | 18.61 ns | 1.40 | - | 0.00 | +| Single-target (generated v2): full lifecycle | .NET 11.0 | False | 15.43 ns | 1.16 | - | 0.00 | +| **Multi-target (manual): start + complete** | **.NET Framework 4.8** | **False** | **70.44 ns** | **1.00** | **24 B** | **1.00** | +| Multi-target (generated v1): start + complete | .NET Framework 4.8 | False | 72.88 ns | 1.03 | 24 B | 1.00 | +| Multi-target (generated v2): start + complete | .NET Framework 4.8 | False | 60.74 ns | 0.86 | 24 B | 1.00 | +| Multi-target (manual): full lifecycle | .NET Framework 4.8 | False | 88.92 ns | 1.26 | 24 B | 1.00 | +| Multi-target (generated v1): full lifecycle | .NET Framework 4.8 | False | 92.15 ns | 1.31 | 24 B | 1.00 | +| Multi-target (generated v2): full lifecycle | .NET Framework 4.8 | False | 80.08 ns | 1.14 | 24 B | 1.00 | +| Single-target (generated v1): full lifecycle | .NET Framework 4.8 | False | 73.28 ns | 1.04 | - | 0.00 | +| Single-target (generated v2): full lifecycle | .NET Framework 4.8 | False | 45.30 ns | 0.64 | - | 0.00 | +| **Multi-target (manual): start + complete** | **.NET 10.0** | **True** | **238.98 ns** | **1.00** | **1032 B** | **1.00** | +| Multi-target (generated v1): start + complete | .NET 10.0 | True | 246.79 ns | 1.03 | 1032 B | 1.00 | +| Multi-target (generated v2): start + complete | .NET 10.0 | True | 220.70 ns | 0.92 | 1032 B | 1.00 | +| Multi-target (manual): full lifecycle | .NET 10.0 | True | 222.75 ns | 0.93 | 1032 B | 1.00 | +| Multi-target (generated v1): full lifecycle | .NET 10.0 | True | 219.33 ns | 0.92 | 1032 B | 1.00 | +| Multi-target (generated v2): full lifecycle | .NET 10.0 | True | 216.81 ns | 0.91 | 1032 B | 1.00 | +| Single-target (generated v1): full lifecycle | .NET 10.0 | True | 18.75 ns | 0.08 | - | 0.00 | +| Single-target (generated v2): full lifecycle | .NET 10.0 | True | 15.28 ns | 0.06 | - | 0.00 | +| **Multi-target (manual): start + complete** | **.NET 11.0** | **True** | **223.96 ns** | **1.00** | **1032 B** | **1.00** | +| Multi-target (generated v1): start + complete | .NET 11.0 | True | 222.29 ns | 0.99 | 1032 B | 1.00 | +| Multi-target (generated v2): start + complete | .NET 11.0 | True | 212.34 ns | 0.95 | 1032 B | 1.00 | +| Multi-target (manual): full lifecycle | .NET 11.0 | True | 239.89 ns | 1.07 | 1032 B | 1.00 | +| Multi-target (generated v1): full lifecycle | .NET 11.0 | True | 233.14 ns | 1.04 | 1032 B | 1.00 | +| Multi-target (generated v2): full lifecycle | .NET 11.0 | True | 214.18 ns | 0.96 | 1032 B | 1.00 | +| Single-target (generated v1): full lifecycle | .NET 11.0 | True | 20.72 ns | 0.09 | - | 0.00 | +| Single-target (generated v2): full lifecycle | .NET 11.0 | True | 16.17 ns | 0.07 | - | 0.00 | +| **Multi-target (manual): start + complete** | **.NET Framework 4.8** | **True** | **592.06 ns** | **1.00** | **1292 B** | **1.00** | +| Multi-target (generated v1): start + complete | .NET Framework 4.8 | True | 579.08 ns | 0.98 | 1292 B | 1.00 | +| Multi-target (generated v2): start + complete | .NET Framework 4.8 | True | 561.83 ns | 0.95 | 1292 B | 1.00 | +| Multi-target (manual): full lifecycle | .NET Framework 4.8 | True | 592.02 ns | 1.00 | 1292 B | 1.00 | +| Multi-target (generated v1): full lifecycle | .NET Framework 4.8 | True | 596.52 ns | 1.01 | 1292 B | 1.00 | +| Multi-target (generated v2): full lifecycle | .NET Framework 4.8 | True | 577.82 ns | 0.98 | 1292 B | 1.00 | +| Single-target (generated v1): full lifecycle | .NET Framework 4.8 | True | 72.80 ns | 0.12 | - | 0.00 | +| Single-target (generated v2): full lifecycle | .NET Framework 4.8 | True | 45.37 ns | 0.08 | - | 0.00 | + +**Interpretation:** Generated and hand-written multi-target implementations allocate identically (1032 B with a listener +active, 24 B without) and land within 9% of each other per scenario on every runtime; v2 is the fastest of the six +multi-target rows on each of the three runtimes (0.79–0.95 against the manual start-complete baseline). Logging-only +accounts for 15–21 ns of the multi-target cost (the two single-target rows: 15.3–20.7 ns on the modern runtimes, +45.4–72.8 ns on .NET Framework), so with a listener active the ~220–600 ns total is Activity creation, not logging or +metrics. Without a listener the multi-target path still builds the metric and log state — 10.5–13.8 ns on .NET 10/11, +less than the 15.4–18.6 ns the four logging-only calls cost, because a multi-target method emits fewer log events. ## Metrics **Source:** `MetricsBenchmarks` and `TagListBenchmarks` -All instruments are **0 allocations** on every runtime. +Compares the generated synchronous instruments against hand-written best-practice code, one instrument call per +benchmark. All instruments are **0 allocations** on every runtime, on both the generated and the hand-written path. -| Scenario | Generated (.NET 10.0) | -| --- | --- | -| auto-counter (0 tags) | 0.40 ns | -| auto-counter (1 tag) | 0.35 ns | -| up-down counter | 0.37 ns | -| histogram (0 tags) | 0.42 ns | -| histogram (1 tag) | 0.34 ns | +| Scenario | Manual (.NET 10.0) | Generated (.NET 10.0) | Manual (.NET 11.0) | Generated (.NET 11.0) | Manual (.NET Framework 4.8) | Generated (.NET Framework 4.8) | +| --- | --- | --- | --- | --- | --- | --- | +| auto-counter (0 tags) | 1.29 ns | 1.31 ns | 1.33 ns | 1.30 ns | 16.16 ns | 16.32 ns | +| auto-counter (1 tag) | 1.32 ns | 1.30 ns | 1.28 ns | 1.29 ns | 19.75 ns | 20.31 ns | +| up-down counter | 1.34 ns | 1.30 ns | 1.28 ns | 1.28 ns | 17.03 ns | 16.86 ns | +| histogram (0 tags) | 1.28 ns | 1.29 ns | 1.29 ns | 1.29 ns | 16.97 ns | 16.67 ns | +| histogram (1 tag) | 1.28 ns | 1.29 ns | 1.31 ns | 1.29 ns | 19.58 ns | 19.68 ns | + +Both implementations are consumed through `IMetricsBenchmarkTelemetry`, so neither side is inlined into the measurement +loop and the comparison is like for like. On .NET 10.0 and .NET 11.0 the generated rows land within 2% of the hand-written +ones — 1.29–1.31 ns against 1.28–1.34 ns — and on .NET Framework 4.8 within 3% (16.3–20.3 ns against 16.2–19.8 ns). Both +sides sit at the same ~1.3 ns per update on the modern runtimes whatever the tag count: at that scale the instrument call +is the cost, and the generator adds nothing measurable to it. The source generator uses a tag-count optimization: methods with fewer than 4 tags pass inline `KeyValuePair` parameters (no heap allocation), while methods with 4 or more tags use a stack-allocated [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist) struct. -| Scenario | .NET 10.0 | -| --- | --- | -| 0 tags: histogram record | 0.38 ns | -| 1 tag: auto-counter add | 0.41 ns | -| 3 tags: histogram record | 0.84 ns | -| 4 tags (TagList): auto-counter add | 7.91 ns | -| 5 tags (TagList): auto-counter add | 5.46 ns | -| 6 tags (TagList): histogram record | 6.37 ns | - -The TagList path costs 14–21× more CPU than the inline path on .NET 10.0, but both remain in the single-digit-nanosecond range with zero allocations. +| Scenario | .NET 10.0 | .NET 11.0 | .NET Framework 4.8 | +| --- | --- | --- | --- | +| 0 tags: histogram record | 1.42 ns | 1.35 ns | 16.95 ns | +| 1 tag: auto-counter add | 1.37 ns | 1.33 ns | 20.06 ns | +| 3 tags: histogram record | 1.94 ns | 1.85 ns | 30.41 ns | +| 4 tags (TagList): auto-counter add | 5.28 ns | 4.93 ns | 48.32 ns | +| 5 tags (TagList): auto-counter add | 6.54 ns | 5.24 ns | 57.15 ns | +| 6 tags (TagList): histogram record | 7.10 ns | 5.34 ns | 60.56 ns | + +The `TagList` path measures 4.9–7.1 ns against 1.3–1.9 ns for the inline paths on the modern runtimes, and 48–61 ns against +17–30 ns on .NET Framework, but both remain allocation-free and in the single-digit-nanosecond range (tens of nanoseconds on +.NET Framework). The cost tracks the number of tags on every runtime, which is the struct population itself rather than any +allocation. + +## Generator model overhead + +**Source:** `GeneratorModelBenchmarks` + +Compares the two modelling strategies the generator's pipeline can use for its tagged hierarchies +(`closed record InstrumentAttributeRecord` / `ActivityMethodTarget`): an abstract record with one sealed case per +instrument and virtual properties, versus a `readonly struct` union carrying a tag plus the case payloads with a +non-boxing `TryGetValue` accessor. Both build the same number of models and read the same four facts off each one, which is +what a generation pass does per method. This is generator-time work, not a consumer hot path: it is included so the +decision recorded in [Platform Support](Platform-Support.md#considered-and-rejected) rests on a measurement. + +| Method | Runtime | MethodCount | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | +| --- | --- | --- | --- | --- | --- | --- | --- | +| **closed record hierarchy: build + dispatch** | **.NET 10.0** | **8** | **80.03 ns** | **1.00** | **0.0267** | **504 B** | **1.00** | +| value-based struct union: build + dispatch | .NET 10.0 | 8 | 41.25 ns | 0.52 | 0.0200 | 376 B | 0.75 | +| **closed record hierarchy: build + dispatch** | **.NET 11.0** | **8** | **76.45 ns** | **1.00** | **0.0267** | **504 B** | **1.00** | +| value-based struct union: build + dispatch | .NET 11.0 | 8 | 41.58 ns | 0.54 | 0.0200 | 376 B | 0.75 | +| **closed record hierarchy: build + dispatch** | **.NET Framework 4.8** | **8** | **130.01 ns** | **1.00** | **0.0815** | **514 B** | **1.00** | +| value-based struct union: build + dispatch | .NET Framework 4.8 | 8 | 193.11 ns | 1.49 | 0.0610 | 385 B | 0.75 | +| **closed record hierarchy: build + dispatch** | **.NET 10.0** | **64** | **631.35 ns** | **1.00** | **0.1926** | **3640 B** | **1.00** | +| value-based struct union: build + dispatch | .NET 10.0 | 64 | 303.11 ns | 0.48 | 0.1388 | 2616 B | 0.72 | +| **closed record hierarchy: build + dispatch** | **.NET 11.0** | **64** | **530.27 ns** | **1.00** | **0.1926** | **3640 B** | **1.00** | +| value-based struct union: build + dispatch | .NET 11.0 | 64 | 295.96 ns | 0.56 | 0.1388 | 2616 B | 0.72 | +| **closed record hierarchy: build + dispatch** | **.NET Framework 4.8** | **64** | **967.34 ns** | **1.00** | **0.5798** | **3659 B** | **1.00** | +| value-based struct union: build + dispatch | .NET Framework 4.8 | 64 | 1,545.25 ns | 1.60 | 0.4177 | 2633 B | 0.72 | + +**Interpretation:** On .NET 10 and .NET 11 the value-based union is roughly twice as fast (0.48–0.56) and allocates +25–28% less. On .NET Framework 4.8 it is the *slower* of the two, 1.49× at 8 methods and 1.60× at 64, because the +struct's copy and tag-switch cost exceeds what the record hierarchy pays there. That is the direction that decided the +question: the records stayed, because the win on the modern runtimes is 35–39 ns at 8 methods and 234–328 ns at 64 methods +per generation pass — a few nanoseconds per method, against a pass that also resolves every symbol, builds the message +templates and writes the source text — and the change would make .NET Framework generation measurably worse. The +exhaustive `switch` over a `closed` hierarchy, checked by the compiler, is the second reason. ## Observable instruments @@ -136,8 +335,13 @@ The TagList path costs 14–21× more CPU than the inline path on .NET 10.0, but ## Raw results -Full CSV and HTML benchmark artifacts are available in [`BenchmarkDotNet.Artifacts/results/`](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/BenchmarkDotNet.Artifacts/results). +The `BenchmarkDotNet.Artifacts/` folder is gitignored, so the CSV and HTML artifacts are not published — they live only on +the machine that ran the suite. To produce them, run the command in [Reproducing](#reproducing); one invocation writes a +`-report-github.md`, a `-report.csv` and a `-report.html` per benchmark class, and the CSVs carry the `Error`, `StdDev` +and `Median` columns the tables above omit. ## See also -- [README.md § Performance](https://github.com/purview-dev/telemetry-sourcegenerator#performance) — condensed summary of the key .NET 10.0 numbers +- [README.md § Performance](https://github.com/purview-dev/telemetry-sourcegenerator#performance) — condensed .NET 10.0 summary of the key numbers +- [Platform Support](Platform-Support.md#considered-and-rejected) — the modelling decision this suite measures +- [Generated Output](Generated-Output.md) — what the generated multi-target implementation looks like diff --git a/docs/wiki/Platform-Support.md b/docs/wiki/Platform-Support.md index 670d4e9a..3cc277bc 100644 --- a/docs/wiki/Platform-Support.md +++ b/docs/wiki/Platform-Support.md @@ -154,7 +154,7 @@ Everything else in .NET 11 was evaluated and deliberately left alone: | Change | Decision and reason | | --- | --- | -| Replacing the generator's own tagged models (`closed record` `InstrumentAttributeRecord` and `ActivityMethodTarget`) with a value-based `[Union]` struct | **Kept as `closed` records.** The models allocate one object per method per generation pass and dispatch through abstract properties, which a value union would remove — but the measured per-method cost of that mechanism is a few tens of nanoseconds, against a pass that also resolves every symbol, builds the message templates and writes the source text, so the ceiling for any win is a fraction of one percent. The win is not even uniform: on .NET Framework a `readonly struct` union with a tag switch is measurably *slower* than the record hierarchy for the same build-and-dispatch loop, so the change would trade a simpler, exhaustiveness-checked model for a worse .NET Framework build. The exhaustive `switch` over a `closed` hierarchy is also enforced by the compiler, which a tag-plus-payload struct does not give. `benchmarks/Purview.Telemetry.Benchmarks` carries `GeneratorModelBenchmarks`, which compares the two shapes on every runtime leg. The tables in [Performance](Performance.md) predate it, so re-run the suite (a Release build, as [Performance](Performance.md#reproducing) describes) before quoting a comparison. | +| Replacing the generator's own tagged models (`closed record` `InstrumentAttributeRecord` and `ActivityMethodTarget`) with a value-based `[Union]` struct | **Kept as `closed` records.** The models allocate one object per method per generation pass and dispatch through abstract properties, which a value union would remove — but the measured per-method cost of that mechanism is a few tens of nanoseconds, against a pass that also resolves every symbol, builds the message templates and writes the source text, so the ceiling for any win is a fraction of one percent. The win is not even uniform: on .NET Framework a `readonly struct` union with a tag switch is measurably *slower* than the record hierarchy for the same build-and-dispatch loop, so the change would trade a simpler, exhaustiveness-checked model for a worse .NET Framework build. The exhaustive `switch` over a `closed` hierarchy is also enforced by the compiler, which a tag-plus-payload struct does not give. `benchmarks/Purview.Telemetry.Benchmarks` carries `GeneratorModelBenchmarks`, which compares the two shapes on every runtime leg. The measured comparison is in [Performance § Generator model overhead](Performance.md#generator-model-overhead): the union is 1.8–2.1× faster on .NET 10 and .NET 11 and allocates 25–28% less, but it is 1.5–1.6× *slower* on .NET Framework 4.8 — which is the direction that decided the question. | ## Not yet supported From 74f902c4cc10b00e88fdb0e63e03eded86a0cb55 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 16:02:26 +0000 Subject: [PATCH 5/8] ci: install the SDK from the shared actions/dotnet-sdk action Four jobs failed on PR #157 before running a single dotnet command: dependency-floor, samples, pack-validation and packed-consumers each installed the SDK with actions/setup-dotnet's global-json-file input, which resolved and installed LTS 10.0.404, reported success, and left the pinned 11.0.100 unmet. rollForward: latestMajor cannot help - it only rolls forward. purview-dev/build#48 extracted the resolution the reusable build workflow already did into actions/dotnet-sdk, so the same one line replaces the broken input in all four jobs. The build job was green throughout because it calls that workflow. --- .github/workflows/pr.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f964d4fc..9c2619b2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,9 +29,11 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json + # Installs the SDK global.json pins (11.0.100). `actions/setup-dotnet` directly, with its + # `global-json-file` input, falls back to the LTS channel when it cannot install the pin, + # reports success, and leaves the first dotnet command to fail with "A compatible .NET SDK was + # not found". The shared action is the one implementation of this resolution. + - uses: purview-dev/build/actions/dotnet-sdk@main - name: Restore, build and test at the declared floor run: >- @@ -53,11 +55,7 @@ jobs: fetch-tags: true - name: Setup .NET - uses: actions/setup-dotnet@v6 - with: - # global.json is the source of truth: the repo targets net11.0, and a floating - # 10.0.x here leaves the SDK pinned by global.json uninstalled. - global-json-file: global.json + uses: purview-dev/build/actions/dotnet-sdk@main - name: Build SampleApp run: dotnet build samples/SampleApp/SampleApp.slnx --configuration Release @@ -95,9 +93,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json + - uses: purview-dev/build/actions/dotnet-sdk@main # Restore before pack: a pwsh step that left `dotnet tool restore`'s non-zero exit code to the shell's # trailing `exit $LASTEXITCODE` is what made this gate report a green run as a failure. Reaching a step at @@ -130,9 +126,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json + - uses: purview-dev/build/actions/dotnet-sdk@main # First, before any toolchain is installed: the generator is built against the # Purview.SourceGeneratorFramework version Directory.Packages.props pins, with the From 39de15a89ef3bdce218b279cbea9363728f421c5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 16:05:27 +0000 Subject: [PATCH 6/8] fix(build): drop dotnet-inspect from the tool manifest again The pack-validation job fails at `dotnet tool restore` with The command "purview-build" specified in the tool manifest file is not contained in the package with Package Id purview.build. The commands contained in the package are dotnet-inspect. That is 7427652's failure returning: 2f1c731 re-added the dotnet-inspect entry. The package is still a DotNetCliTool Version="2" with RuntimeIdentifierPackages, laid out under tools/any/any, and beside another tool NuGet validates the wrong package's commands on the Linux runner. It restores on Windows, which is why the local loop never saw it. Nothing in the repository runs the tool. Removed again, and ToolManifestTests now rejects it by name so it cannot come back a third time. AGENTS.md records the manifest rule: csharpier and purview.build, nothing RID-specific. Verified with an empty NUGET_PACKAGES folder and --no-cache: csharpier and purview.build restore, and the guard fails when the entry is put back. --- .config/dotnet-tools.json | 7 -- AGENTS.md | 6 ++ .../Repository.UnitTests/ToolManifestTests.cs | 73 +++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/tests/Repository.UnitTests/ToolManifestTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bc9bc278..f216ffd9 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,13 +9,6 @@ ], "rollForward": false }, - "dotnet-inspect": { - "version": "0.27.0", - "commands": [ - "dotnet-inspect" - ], - "rollForward": false - }, "purview.build": { "version": "0.9.0", "commands": [ diff --git a/AGENTS.md b/AGENTS.md index 66240220..d4790466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,12 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel 1.4.0 parses C# 15, so the two files that declare a `closed` hierarchy are formatted like any other and the `.csharpierignore` those entries lived in is gone. Do not reintroduce an ignore file to work around the parser; a file it cannot read is a version bump, not an exclusion. +- **Local tools**: `.config/dotnet-tools.json` holds `csharpier` and `purview.build`, and nothing RID-specific. A + `DotNetCliTool Version="2"` package (one with `RuntimeIdentifierPackages`, laid out under `tools/any/any`) breaks a + cold `dotnet tool restore` on a hosted runner when it sits beside another tool: NuGet validates the wrong package's + commands and names the other entry. `dotnet-inspect` did exactly that - removed in `7427652`, re-added by accident in + `2f1c731`, which took the `pack-validation` job down again - so it is rejected by name in `ToolManifestTests`. + Nothing here runs it; do not add it or any tool of that shape back. - **Solutions**: `.slnx` solution files are used. - **Source generator**: targets `netstandard2.0` for broad compiler-host compatibility and uses `Purview.SourceGeneratorFramework` (CodeWriter-based emission, incremental pipeline, value-equatable diff --git a/src/tests/Repository.UnitTests/ToolManifestTests.cs b/src/tests/Repository.UnitTests/ToolManifestTests.cs new file mode 100644 index 00000000..39db274b --- /dev/null +++ b/src/tests/Repository.UnitTests/ToolManifestTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the local tool manifest (.config/dotnet-tools.json) against a shape that only fails on a +/// cold NuGet cache - which is every hosted run and every fresh clone. +/// +/// +/// A RID-specific tool (DotNetCliTool Version="2" with RuntimeIdentifierPackages, laid out under +/// tools/any/any) installed beside another tool makes NuGet validate the wrong package's commands, so a +/// cold dotnet tool restore fails with a message that names the other entry: +/// +/// The command "purview-build" specified in the tool manifest file is not contained in the package with +/// Package Id purview.build. The commands contained in the package are dotnet-inspect. +/// +/// dotnet-inspect is the tool this repository has hit it with, twice: removed in 7427652 and +/// re-added by accident in 2f1c731, which took the pack-validation PR job down again. Nothing here +/// runs it, so the entry is rejected by name; a new tool of the same shape needs its own line and the same +/// reasoning. +/// +public sealed class ToolManifestTests +{ + /// Tools known to break a cold restore; each is rejected below with why. + static readonly string[] RidSpecificTools = ["dotnet-inspect"]; + + [Test] + public async Task Manifest_GivenAKnownRidSpecificTool_IsRejected(CancellationToken cancellationToken) + { + // Arrange + var manifestPath = Path.Combine(FindRepositoryRoot(), ".config", "dotnet-tools.json"); + await using var stream = File.OpenRead(manifestPath); + using var manifest = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + // Act + var present = manifest + .RootElement.GetProperty("tools") + .EnumerateObject() + .Select(static property => property.Name) + .Intersect(RidSpecificTools, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // Assert + await Assert + .That(present) + .IsEmpty() + .Because( + "a RID-specific tool in the manifest makes a cold 'dotnet tool restore' fail with a message that " + + $"names the wrong package, which breaks every hosted run: {string.Join(", ", present)}" + ); + } + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "skills-lock.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, skills-lock.json and src/src." + ); + } +} From fadb8d2860cfc90dc2ab0d150b43937944ae61a1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 16:11:34 +0000 Subject: [PATCH 7/8] ci: make the Native AOT prerequisite install survive a missing mirror The packed-consumers job failed twice in one afternoon at E: Failed to fetch https://security.ubuntu.com/.../clang_18.0-59~exp2_amd64.deb Temporary failure resolving 'azure.archive.ubuntu.com' Every other job in both runs was green, so this was the last red on PR #157. The mirror is the one the runner's /etc/apt/apt-mirrors.txt points at, and the failure is DNS, not a package problem: the apt call is the first thing the job does after the preflight, so nothing downstream of it had a chance to run. The step now probes with dpkg first - the ubuntu-latest image usually carries clang and zlib1g-dev, in which case no network is touched at all - and retries the install with backoff three times when it does not. --- .github/workflows/pr.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9c2619b2..9459313f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -137,8 +137,37 @@ jobs: run: ./scripts/test-packed-consumers.ps1 -PreflightOnly # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + # + # The install is best-effort in the two ways that have actually failed: the image often already + # has both (then no network is touched at all), and `azure.archive.ubuntu.com` - the mirror the + # runner's apt-mirrors.txt points at - intermittently fails to resolve, which killed this job + # twice in one afternoon with "Temporary failure resolving 'azure.archive.ubuntu.com'" while + # every other job stayed green. Retries cover the second; the probe covers the first. - name: Install the Native AOT prerequisites - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + run: | + set -euo pipefail + + missing="" + for package in clang zlib1g-dev; do + dpkg -s "$package" >/dev/null 2>&1 || missing="$missing $package" + done + + if [ -z "$missing" ]; then + echo "clang and zlib1g-dev are already installed on this image." + exit 0 + fi + + for attempt in 1 2 3; do + sudo apt-get update -o Acquire::Retries=3 || true + if sudo apt-get install -y --no-install-recommends $missing; then + exit 0 + fi + echo "apt-get could not install$missing (attempt $attempt of 3); retrying in $((attempt * 20))s" + sleep $((attempt * 20)) + done + + echo "could not install$missing after 3 attempts" >&2 + exit 1 # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. # -WasmAot Require below makes a missing workload an error instead of a skipped step. From 4217c3161aaf61055ddfef172725614237e4f96b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 16:39:36 +0000 Subject: [PATCH 8/8] ci: stop the Native AOT prerequisites step hanging on a dead mirror The retry loop added in fadb8d2 made the failure worse: each apt call blocked on DNS for azure.archive.ubuntu.com for minutes, three attempts in a row, so the step ran 24 minutes and still failed. It is the only step in the job that needs the network, and the first after the preflight, so nothing downstream ran at all. The step now prefers what the image already has - a versioned clang-18 linked as clang when the unversioned name is missing, and /usr/include/zlib.h - and only touches apt when a package really is absent, with -o Acquire::Retries=0, 15s fetch timeouts, timeout(1) around each call and a 5 minute step timeout. A dead mirror now costs seconds instead of half an hour. If the image has neither a clang nor the headers, the next suspect is the runner label: the run warns that ubuntu-latest migrated to Ubuntu 26 on 2026-10-19, and the mirror this job fails to reach is the one that image's apt-mirrors.txt points at. --- .github/workflows/pr.yml | 51 +++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9459313f..72ebf5ca 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -136,38 +136,47 @@ jobs: shell: pwsh run: ./scripts/test-packed-consumers.ps1 -PreflightOnly - # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + # Native AOT links with clang and needs the zlib headers. Only the last of these needs the network: + # - `clang` is on PATH; or a versioned `clang-18` is installed and only the unversioned name is + # missing, which the hosted image does - link it instead of installing anything; + # - `/usr/include/zlib.h` is present, as it usually is; + # - a package really is missing - install it with short timeouts and no retries. # - # The install is best-effort in the two ways that have actually failed: the image often already - # has both (then no network is touched at all), and `azure.archive.ubuntu.com` - the mirror the - # runner's apt-mirrors.txt points at - intermittently fails to resolve, which killed this job - # twice in one afternoon with "Temporary failure resolving 'azure.archive.ubuntu.com'" while - # every other job stayed green. Retries cover the second; the probe covers the first. + # Never let apt retry: the runner's mirror (azure.archive.ubuntu.com, via apt-mirrors.txt) + # intermittently fails to resolve, and every attempt hangs for minutes per repository. Three + # attempts took this job to 24 minutes and still failed. Failing in seconds is the better outcome, + # and the probe means the usual case never reaches apt at all. - name: Install the Native AOT prerequisites + timeout-minutes: 5 run: | set -euo pipefail + if ! command -v clang >/dev/null 2>&1; then + versioned="$(ls /usr/bin/clang-[0-9]* 2>/dev/null | sort -V | tail -1 || true)" + if [ -n "$versioned" ]; then + echo "no unversioned clang on PATH; using $versioned" + sudo ln -sf "$versioned" /usr/local/bin/clang + fi + fi + missing="" - for package in clang zlib1g-dev; do - dpkg -s "$package" >/dev/null 2>&1 || missing="$missing $package" - done + command -v clang >/dev/null 2>&1 || missing="$missing clang" + [ -f /usr/include/zlib.h ] || missing="$missing zlib1g-dev" if [ -z "$missing" ]; then - echo "clang and zlib1g-dev are already installed on this image." + echo "clang and the zlib headers are present." exit 0 fi - for attempt in 1 2 3; do - sudo apt-get update -o Acquire::Retries=3 || true - if sudo apt-get install -y --no-install-recommends $missing; then - exit 0 - fi - echo "apt-get could not install$missing (attempt $attempt of 3); retrying in $((attempt * 20))s" - sleep $((attempt * 20)) - done - - echo "could not install$missing after 3 attempts" >&2 - exit 1 + echo "installing$missing" + if ! sudo timeout 120 apt-get install -y --no-install-recommends $missing \ + -o Acquire::Retries=0 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15; then + echo "install failed from the cached lists; refreshing them once" + sudo timeout 90 apt-get update \ + -o Acquire::Retries=0 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 || true + sudo timeout 120 apt-get install -y --no-install-recommends $missing \ + -o Acquire::Retries=0 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 + fi # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. # -WasmAot Require below makes a missing workload an error instead of a skipped step.