diff --git a/.agents/agents/source-generator-framework-writer.agent.md b/.agents/agents/source-generator-framework-writer.agent.md new file mode 100644 index 00000000..65f5bd03 --- /dev/null +++ b/.agents/agents/source-generator-framework-writer.agent.md @@ -0,0 +1,79 @@ +--- +name: Source Generator Framework Writer +description: "Specialist for Purview.SourceGeneratorFramework generation code using CodeWriter and XmlCodeWriter-style XML doc extensions; ideal for creating or refactoring generator emitters." +tools: + [ + "search/codebase", + "edit/editFiles", + "search", + "execute/getTerminalOutput", + "execute/runInTerminal", + "read/terminalLastCommand", + "read/terminalSelection", + "execute/createAndRunTask", + "execute/runTask", + "read/getTaskOutput", + "vscodeTasks/createAndRunTask", + "vscodeTasks/getTaskOutput", + "vscodeTasks/runTask", + ] +--- + +You are a specialist for `Purview.SourceGeneratorFramework` emitter authoring. + +## Primary objective + +Produce clear, deterministic, maintainable source-generator emission code using `CodeWriter` and XML extension helpers from `XmlCommentWriter`. + +## Background knowledge + +Before changing any source generator, analyser, or CodeWriter-related code, load and apply the `source-generator-codewriter-modernization` skill. It contains the full source-generator, analyser, and CodeWriter best-practices guidance for this framework, including incremental pipeline design, value equality, deterministic output, and Roslyn version compatibility. + +The most important rules are: + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` whenever possible.** +- **Remove `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxTree`, `SyntaxNode`, and `Location` from incremental pipeline models as early as possible.** +- **Pipeline models must be immutable and value-equatable; use `EquatableArray` for collections.** +- **Avoid `Collect()` until global knowledge is genuinely required.** +- **Never combine `CompilationProvider` into the pipeline merely because it is convenient.** +- **Generate deterministic output and stable hint names.** +- **Test incrementally, not just generated text.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Create `CodeWriter` inside the output callback and pass it to helpers within that callback; never create it earlier in the pipeline or store it in incremental provider state or custom contexts.** + +## Available resources + +- `skills/source-generator-codewriter-modernization/SKILL.md` — source-generator, analyser, and CodeWriter best practices for this framework. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. + +## Must-follow rules + +1. Load and apply the `source-generator-codewriter-modernization` skill. +2. Prefer structured declaration APIs over handwritten declaration strings. +3. Prefer XML helper extensions (`XmlSummary`, `XmlParam`, etc.) over raw `///` output. +4. Create `CodeWriter` inside each output callback; never create it earlier in the pipeline or cache it in incremental provider state or custom contexts. +5. Preserve semantic behavior while modernizing implementation style. +6. Keep edits minimal and localized to emitter concerns. + +## Refactoring posture + +When modernizing legacy code: + +- Replace manual indentation/braces with scope APIs. +- Replace signature text with declaration option records. +- Replace ad-hoc XML tags with helper APIs. +- Preserve diagnostics and emitted symbol names. + +## Quality gates + +- Build/tests pass for impacted projects. +- No scope leaks when materializing generated source. +- Generated artifacts remain deterministic and reviewable. + +## Skill routing + +When relevant, first load and apply: + +- `source-generator-codewriter-modernization` diff --git a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md b/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md new file mode 100644 index 00000000..e230d224 --- /dev/null +++ b/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md @@ -0,0 +1,52 @@ +--- +agent: ask +description: "Refactor a legacy source generator emitter from string/StringBuilder to CodeWriter + XmlCodeWriter-style XML extensions with behavior parity." +--- + +You are modernizing a source generator implementation in this repository. Apply the guidance from the `source-generator-codewriter-modernization` skill for incremental pipelines, value equality, deterministic output, and CodeWriter scope safety. + +## Inputs + +- Target file(s): `${input:targetFiles:Path(s) to emitter file(s)}` +- Generator type name: `${input:generatorName:Generator class name}` +- Generator version: `${input:generatorVersion:Version string (for generated attributes/header)}` +- Keep output byte-identical where possible: `${input:preserveFormatting:true|false}` + +## Task + +Refactor the selected legacy emitter implementation from manual `string` / `StringBuilder` output construction to `CodeWriter` and XML documentation extension helpers from `XmlCommentWriter` (XmlCodeWriter-style API usage). + +### Requirements + +1. Use structured declaration APIs where applicable: + - `Class/Struct/RecordClass/Interface/Enum` + - `Method`, `Property`, `Field`, `Constructor` +2. Use XML helper extensions instead of raw `///` composition: + - `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` or `XmlCodeBlock` +3. Use `TypeReference` when type text becomes complex (nullability, generics, arrays). +4. Ensure writer lifetime is output-scoped (`generationContext.CreateCodeWriter()` inside callback). +5. Preserve behavior, diagnostics, and generated names. +6. Keep changes minimal and focused; do not reformat unrelated logic. + +### Migration strategy + +- Identify emitter phases: header, namespace, type declarations, member declarations. +- Replace indentation/braces with scoped APIs. +- Replace signature strings with declaration options. +- Replace XML comments with XmlCommentWriter extension methods. +- Keep semantic equivalence; call out any intentional deltas. + +### Verification + +- Run relevant tests. +- Confirm generated files still compile. +- Confirm no `CodeWriter` scope leaks (`OpenScopeCount == 0` when materialized). + +### Output format + +Return: + +1. Files changed +2. Why each change was necessary +3. Risks/behavior differences (if any) +4. Verification performed diff --git a/AGENTS.md b/AGENTS.md index 3ecc8185..0f8804ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ All `just` recipes read the repository's [`Justfile`](Justfile). Configuration d | `just test` | Runs the test solution (Debug) with a tree-node filter. | | `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`. | +| `just run-mp` | Runs the multi-project sample, printing the aggregated telemetry names. | | `just clean` | Cleans the main solution. | | `just restore` | Restores packages for the main solution. | | `just scrub` | Removes `bin`/`obj` folders, cleans, restores, and shuts down build servers. | @@ -62,7 +64,7 @@ conventional-commits check. | `just update-version` | Runs `.build/update-version.ts` to sync the version into docs/samples. | | `just pack` | Updates the version then packs the NuGet package into `artifacts/`. | -The version lives in `package.json`. **Current Version:** 5.0.0 — applied to `Version` / +The version lives in `package.json`. **Current Version:** 5.0.2 — applied to `Version` / `PackageVersion` via the SDK's package.json version detection. ### Pipelines (reusable `purview-build` tool) @@ -114,6 +116,8 @@ src/ samples/ ├── SampleApp/ # .NET Aspire demo (AppHost, APIService, Web, Shared, ServiceDefaults, UnitTests) │ └── SampleApp.slnx +├── SampleApp.MultiProject/ # .NET 10 console demo of TelemetryNames aggregation across projects +│ └── SampleApp.MultiProject.slnx # (Observability, Notifications, Payments, Ordering, ConsoleApp) └── SampleApp.Net48/ # .NET Framework 4.8 console demo └── SampleApp.Net48.slnx diff --git a/Directory.Packages.props b/Directory.Packages.props index 1b19636d..6fa522a8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,13 @@ + + + + diff --git a/Justfile b/Justfile index cdb1cd31..075fed5b 100644 --- a/Justfile +++ b/Justfile @@ -9,6 +9,8 @@ pipeline_feed := "https://api.nuget.org/v3/index.json" pipeline_tool := ".tools/purview-build/purview-build" sample_solution_file := "./samples/SampleApp/SampleApp.slnx" +multiproject_sample_solution_file := "./samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx" +multiproject_sample_project := "./samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp" artifact_folder := "./artifacts/" benchmark_solution := "./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj" @@ -135,6 +137,20 @@ test-s: echo "Running tests for {{ BLUE }}{{ sample_solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet test --solution "{{ sample_solution_file }}" --configuration "{{ build_configuration }}" +# Builds the multi-project sample solution with the specified configuration + +[group('Samples - Build and Test')] +build-mp: + echo "Building {{ BLUE }}{{ multiproject_sample_solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." + dotnet build "{{ multiproject_sample_solution_file }}" --configuration "{{ build_configuration }}" + +# Runs the multi-project sample, printing the aggregated telemetry names + +[group('Samples - Build and Test')] +run-mp: + echo "Running {{ BLUE }}{{ multiproject_sample_project }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." + dotnet run --project "{{ multiproject_sample_project }}" --configuration "{{ build_configuration }}" + # ----------------------------------------------------------------------------- # Formatting # ----------------------------------------------------------------------------- @@ -188,12 +204,17 @@ code: code "{{ root_folder }}" # Opens the sample solution in the default associated application - [group('Utilities')] vs-s: echo "Opening {{ BLUE }}{{ sample_solution_file }}{{ NORMAL }}..." open "{{ sample_solution_file }}" +# Opens the multiproject sample solution in the default associated application +[group('Utilities')] +vs-mp: + echo "Opening {{ BLUE }}{{ multiproject_sample_solution_file }}{{ NORMAL }}..." + open "{{ multiproject_sample_solution_file }}" + # Clean up the repository by removing build artifacts, bin/obj folders etc, and shutting down the build server [group('Utilities')] scrub: diff --git a/README.md b/README.md index 4e69705e..ec21518a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system Add to your `Directory.Build.props` or `.csproj` file: ```xml - + all analyzers diff --git a/docs/release-process.md b/docs/release-process.md index 8265bd87..34cce6f7 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -70,7 +70,7 @@ Merging to `main` triggers `release.yml`, which runs the reusable `purview-relea ## Versioning -- The version lives in `package.json`. **Current Version:** 5.0.0 +- The version lives in `package.json`. **Current Version:** 5.0.2 - It is applied to `Version` / `PackageVersion` by `Purview.BuildSdk` via package.json version detection (`UsePackageJsonVersion`, default `true`). - `just version` prints the current version. diff --git a/docs/wiki/Breaking-Changes.md b/docs/wiki/Breaking-Changes.md index 907b110e..0f9e58eb 100644 --- a/docs/wiki/Breaking-Changes.md +++ b/docs/wiki/Breaking-Changes.md @@ -7,6 +7,7 @@ This page documents breaking changes between major versions to help you migrate - [v3 to v4](#v3-to-v4) - [Namespace consolidation](#namespace-consolidation) - [OpenTelemetry-aligned naming](#opentelemetry-aligned-naming) +- [v5.0.1 to v5.0.2](#v501-to-v502) - [v4 to v5](#v4-to-v5) - [v1 and v2 to v3](#v1-and-v2-to-v3) @@ -131,6 +132,22 @@ public enum NamingConvention > [!TIP] > For high-impact scenarios, consider using `NamingConvention.Legacy` initially, then gradually migrating to OpenTelemetry conventions during a planned maintenance window. +## v5.0.1 to v5.0.2 + +**Impact:** Low — affects projects that consume the generated `TelemetryNames` class + +### `TelemetryNames` is now opt-in + +The generator now records each assembly's generated activity source and meter names in an assembly-level `[GeneratedTelemetryNames]` attribute (`GenerateTelemetryNamesAttribute`, default `true`), which downstream compilations read to aggregate the names of everything they reference. Because that attribute replaces the per-assembly class as the way names travel, `GenerateTelemetryNamesClass` now defaults to `false`. + +If a project uses `TelemetryNames.MeterNames` / `TelemetryNames.ActivitySourceNames`, opt it back in: + +```csharp +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` + +The class it generates now also includes the names recorded by referenced assemblies, so a host project no longer needs one `TelemetryNames` reference per library. Set `AggregateReferencedTelemetryNames = false` for the previous assembly-only behaviour. See [Generation](Generation.md#telemetry-names). + ## v4 to v5 **Impact:** Low to Medium — affects the default meter name diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 103ffecf..19a29a64 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -204,6 +204,30 @@ interface IOrderMetrics The default `MeterNameGenerationType.DotNet` does not add the meter-name prefix. See [Metrics](Metrics.md#meter-naming). +## Registering names + +### How do I register the names from every project in a large solution? + +Each assembly records its own generated names in metadata (`[assembly: GeneratedTelemetryNames]`), and the `TelemetryNames` class aggregates them across the reference graph. Opt the project that registers telemetry into the class: + +```csharp +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` + +```csharp +builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); +``` + +That project needs no telemetry interfaces of its own — the assembly-level attribute is enough, and the class is filled from what its references recorded. + +Libraries need no configuration at all, however many of them there are and however complex their telemetry. The aggregate is built at compile time, so it is safe under trimming and native AOT, and it covers transitive references too. + +`samples/SampleApp.MultiProject` is a runnable example: four assemblies own telemetry, two of them only transitively referenced by the host, and one `ServiceDefaults`-style project receives the complete set of names (`just run-mp`). + +### Why can't my `ServiceDefaults` project generate the names itself? + +Because of the reference direction: an Aspire-style `ServiceDefaults` is referenced *by* the projects that own the telemetry, so their assemblies are not in its reference graph. Generate the class in each host (executable) project and pass the arrays into `ServiceDefaults`, as the [sample application](Sample-Application.md#registering-names-with-aspire) does. + ## Multi-targeting ### Can I generate Activities, Logging, AND Metrics from one interface? diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/Generated-Output.md index 4dfe448f..a4ab6936 100644 --- a/docs/wiki/Generated-Output.md +++ b/docs/wiki/Generated-Output.md @@ -28,7 +28,7 @@ interface IEntityStoreTelemetry } ``` -generates one partial `EntityStoreTelemetryCore` class split across Activity, Logging, and Metric files, plus a DI extension class and an assembly-level `TelemetryNames` class. +generates one partial `EntityStoreTelemetryCore` class split across Activity, Logging, and Metric files, plus a DI extension class and an assembly-level `[GeneratedTelemetryNames]` attribute recording the generated names. > [!NOTE] > To inspect the generated output in your own project, enable `EmitCompilerGeneratedFiles` and look under `obj///generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. @@ -237,21 +237,31 @@ namespace Microsoft.Extensions.DependencyInjection } ``` +## Recorded names (`SampleApp.APIService.GeneratedTelemetryNames.g.cs`) + +```csharp +[assembly: global::Purview.Telemetry.GeneratedTelemetryNamesAttribute(new string[] { "SampleApp.APIService" }, new string[] { "SampleApp.APIService" })] +``` + +Emitted for every assembly that generates an ActivitySource or a Meter, so downstream assemblies can read the names back out of metadata. Both names default to the assembly name here; an assembly-level `[ActivitySourceGeneration]` or `[MeterGeneration]`, or a name on the interface attribute, overrides them. + ## Telemetry names (`SampleApp.APIService.TelemetryNames.g.cs`) +Opt in with `[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)]`: + ```csharp namespace SampleApp.APIService { public static partial class TelemetryNames { - public static readonly string[] MeterNames = new string[] { "SampleApp.APIService" }; + public static readonly string[] MeterNames = new string[] { "SampleApp.APIService", "SampleApp.Shared" }; - public static readonly string[] ActivitySourceNames = new string[] { "sample-weather-app-api" }; + public static readonly string[] ActivitySourceNames = new string[] { "SampleApp.APIService", "SampleApp.Shared" }; } } ``` -The assembly-level `[assembly: ActivitySourceGeneration("sample-weather-app-api")]` supplies the ActivitySource name; the meter name defaults to the assembly name. +`SampleApp.Shared` comes from the referenced project of the same name, whose `IWeatherForecastTelemetry` declares an ActivitySource and a Meter: the class aggregates this assembly's names with the names recorded by everything it references. See [Generation](Generation.md#telemetry-names). ## See also diff --git a/docs/wiki/Generation.md b/docs/wiki/Generation.md index 5169b3e5..69bdd3ad 100644 --- a/docs/wiki/Generation.md +++ b/docs/wiki/Generation.md @@ -31,9 +31,11 @@ interface IOrderServiceTelemetry { } | `DependencyInjectionClassName` | `null` | Overrides the DI extension class name. When unset, the name is `{implementationClassName}DIExtension`. | | `DependencyInjectionClassIsPublic` | `false` | When `true` the DI extension class is `public`; otherwise it is `internal`. | | `NamingConvention` | `NamingConvention.OpenTelemetry` | Controls generated telemetry names; see [Naming conventions](#naming-conventions). | -| `GenerateTelemetryNamesClass` | `true` | When `false`, suppresses the whole-assembly `TelemetryNames` class. | +| `GenerateTelemetryNamesAttribute` | `true` | Emits the assembly-level `[GeneratedTelemetryNames]` attribute recording this assembly's generated names, so downstream assemblies can aggregate them. Set to `false` to keep the names out of the assembly's metadata. | +| `GenerateTelemetryNamesClass` | `false` | When `true`, emits the whole-assembly `TelemetryNames` class. Opt-in: the names are already recorded in assembly metadata, so only the project that registers them needs the class. | | `TelemetryNamesClassName` | `null` | Custom name for the `TelemetryNames` class (default `TelemetryNames`). | | `TelemetryNamesNamespace` | `null` | When set, relocates the implementation classes, the DI extension class, and the `TelemetryNames` class into this namespace. | +| `AggregateReferencedTelemetryNames` | `true` | When `true`, the `TelemetryNames` class also includes the names recorded by referenced assemblies. Set to `false` for this assembly's names only. | Resolution order is interface-level first, then assembly-level, then built-in defaults. @@ -84,9 +86,21 @@ public static class OrderServiceTelemetryCoreDIExtension - The generated registration is `services.AddSingleton()`. - Set `GenerateDependencyExtension = false` to disable it, or `DependencyInjectionClassIsPublic = true` to make the class public. -## The `TelemetryNames` class +## Telemetry names -When a compilation contains at least one `[ActivitySource]` or `[Meter]` target, the generator emits a single `TelemetryNames` static class per assembly with the distinct source names: +### `[assembly: GeneratedTelemetryNames]` + +When a compilation contains at least one `[ActivitySource]` or `[Meter]` target, the generator records that assembly's distinct source names in an assembly-level attribute: + +```csharp +[assembly: GeneratedTelemetryNames(new string[] { "MyApp.Orders" }, new string[] { "MyApp.Orders" })] +``` + +This is emitted for every assembly by default (`GenerateTelemetryNamesAttribute = true`) and needs no configuration. It is a compile-time record — no reflection and no assembly scanning — so it works under trimming and native AOT, and it is what lets one project collect the names of everything it references. Set `GenerateTelemetryNamesAttribute = false` to keep an assembly's names out of its metadata. + +### The `TelemetryNames` class + +Set `GenerateTelemetryNamesClass = true` to also emit a static class of names, aggregating this assembly's names with the names recorded by every referenced assembly: ```csharp public static class TelemetryNames @@ -102,7 +116,21 @@ It is used to register names with OpenTelemetry/ServiceDefaults: builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` -Control it with `GenerateTelemetryNamesClass`, `TelemetryNamesClassName`, and `TelemetryNamesNamespace`. +The class is opt-in because only the project that registers the names needs it. Turn it on in the executable or host project — the libraries it references publish their names through the attribute: + +```csharp +// Program.cs or Properties/AssemblyInfo.cs of the host project +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` + +The names are deduplicated and ordered, and the aggregate covers the whole reference graph that the compiler was handed — which includes transitive project and package references in a normal MSBuild build. + +The host project does not need telemetry of its own. An API or console project that defines no `[ActivitySource]` or `[Meter]` interface still gets the class from the assembly-level attribute alone, containing the names its references recorded. If neither the project nor its references generated any names, nothing is emitted. + +> [!NOTE] +> The reference direction still applies: an Aspire-style `ServiceDefaults` project is referenced *by* the projects that own the telemetry, so it cannot aggregate them. Generate the class in the host project and pass the arrays into `ServiceDefaults`, as the [sample application](Sample-Application.md) does. + +Control the two outputs with `GenerateTelemetryNamesAttribute` (default `true`) and `GenerateTelemetryNamesClass` (default `false`), plus `TelemetryNamesClassName`, `TelemetryNamesNamespace`, and `AggregateReferencedTelemetryNames` for the class. ## `[Exclude]` diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 7e86845d..d6d732c4 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -121,12 +121,19 @@ A single method call can emit an Activity, a log entry, and a metric simultaneou ## Register names with OpenTelemetry -The generator also produces a `TelemetryNames` static class containing the meter and activity source names: +The generator records the generated meter and activity source names in every assembly's metadata. In the project that registers them, opt into the `TelemetryNames` class, which aggregates its own names with those of every assembly it references: + +```csharp +// Program.cs or Properties/AssemblyInfo.cs +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` +See [Generation](Generation.md#telemetry-names) for the full behaviour across multi-project solutions. + ## Common patterns ### Pattern 1: Scoped logging diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 53224e1c..b5c2f4fd 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -122,6 +122,12 @@ services.AddOrderServiceTelemetry(); ### .NET Aspire +Opt the host project into the `TelemetryNames` class — it aggregates the names of every assembly it references — then pass the arrays into `ServiceDefaults`: + +```csharp +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` + ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` diff --git a/docs/wiki/Sample-Application.md b/docs/wiki/Sample-Application.md index e11989f0..b65e49f9 100644 --- a/docs/wiki/Sample-Application.md +++ b/docs/wiki/Sample-Application.md @@ -9,7 +9,7 @@ The [.NET Aspire sample](https://github.com/purview-dev/telemetry-sourcegenerato | `SampleApp.AppHost` | The Aspire orchestrator. | | `SampleApp.APIService` | Backend service exposing the telemetry interfaces and the generated `TelemetryNames` registration. | | `SampleApp.APIService.UnitTests` | TUnit + NSubstitute unit tests over the generated interfaces. | -| `SampleApp.Shared` | Shared DTOs (e.g. `WeatherForecast`). | +| `SampleApp.Shared` | Shared DTOs (e.g. `WeatherForecast`) plus `IWeatherForecastTelemetry` — a library-owned ActivitySource and Meter, aggregated by each host. | | `SampleApp.Web` | Frontend client that also generates its own telemetry. | | `SampleApp.ServiceDefaults` | Aspire service defaults; registers the generated meter/activity source names. | @@ -134,12 +134,38 @@ public interface IWeatherAPIClientTelemetry ## Registering names with Aspire -`SampleApp.ServiceDefaults` wires the generated names into Aspire's OpenTelemetry setup: +`SampleApp.ServiceDefaults` is referenced *by* the projects that own the telemetry, so it cannot discover their names itself. Instead, each host project opts into the generated `TelemetryNames` class and passes the arrays in. In `SampleApp.APIService/Properties/AssemblyInfo.cs`: + +```csharp +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` +The class aggregates the host's own names with the names recorded by every assembly it references. `SampleApp.Shared` ships its own `IWeatherForecastTelemetry` (an ActivitySource and a Meter, both named after that assembly), consumed by its `WeatherForecastValidator` which `WeatherService` calls. The library registers its services with `builder.Services.AddWeatherForecastValidation()` but never registers its names with OpenTelemetry — those arrive through the project reference: + +```csharp +public static readonly string[] MeterNames = new string[] { "SampleApp.APIService", "SampleApp.Shared" }; + +public static readonly string[] ActivitySourceNames = new string[] { "SampleApp.APIService", "SampleApp.Shared" }; +``` + +`SampleApp.Web` references `SampleApp.Shared` for the DTOs only, so it aggregates the same names without emitting from them — the aggregate follows the reference graph, not usage, and registering a source that stays quiet costs nothing. + +See [Generation](Generation.md#telemetry-names). + +## Multi-project sample + +`samples/SampleApp.MultiProject` is a smaller console solution dedicated to this aggregation, for solutions with many projects and differing telemetry requirements. Four assemblies own telemetry — two of them reachable from the host only transitively — and a `ServiceDefaults`-style project that every one of them references receives the complete set of names: + +```bash +just run-mp +``` + +See its [README](https://github.com/purview-dev/purview-telemetry-sourcegenerator/tree/main/samples/SampleApp.MultiProject) for the reference graph and the generated output. + ## Unit testing `SampleApp.APIService.UnitTests` uses TUnit with NSubstitute to verify `WeatherService` behaviour against the generated interfaces without emitting real telemetry. See [Testing](Testing.md) for the pattern. diff --git a/package.json b/package.json index dd797a72..5cd1cc09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-telemetry-sourcegenerator", - "version": "5.0.1", + "version": "5.0.2", "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.MultiProject/Directory.Build.props b/samples/SampleApp.MultiProject/Directory.Build.props new file mode 100644 index 00000000..62df26b4 --- /dev/null +++ b/samples/SampleApp.MultiProject/Directory.Build.props @@ -0,0 +1,19 @@ + + + net10.0 + SampleApp.MultiProject + true + + true + + $(NoWarn);CA1303; + + + + diff --git a/samples/SampleApp.MultiProject/README.md b/samples/SampleApp.MultiProject/README.md new file mode 100644 index 00000000..30658aae --- /dev/null +++ b/samples/SampleApp.MultiProject/README.md @@ -0,0 +1,140 @@ +# Multi-Project Sample (.NET 10) + +A console solution that shows how telemetry names travel across a **multi-project** solution: each +library records the names it generates in its own assembly metadata, and the one project that +registers telemetry aggregates all of them — including names it only reaches transitively. + +This is the pattern to use when a large solution has many projects with different telemetry +requirements, and a single `ServiceDefaults`-style project needs the complete set of names for +`AddSource(...)` / `AddMeter(...)`. + +## Reference graph + +```text +SampleApp.MultiProject.ConsoleApp # the host: aggregates and registers +└── SampleApp.MultiProject.Ordering # tracing + logging + metrics (default names) + └── SampleApp.MultiProject.Payments # tracing + metrics (explicit names) + └── SampleApp.MultiProject.Notifications # tracing only (explicit name) + +SampleApp.MultiProject.Observability # referenced BY all of the above +``` + +The host references **only** `Ordering` directly. `Payments` and `Notifications` are transitive, and +their names are aggregated all the same. + +`Observability` is the `ServiceDefaults` analogue: because everything references *it*, its own +reference graph contains none of them, so it can never discover their names. It takes the two arrays +as parameters instead. + +## How the names get there + +Every project with an `[ActivitySource]` or `[Meter]` gets this emitted automatically, with no +configuration at all: + +```csharp +// SampleApp.MultiProject.Notifications.GeneratedTelemetryNames.g.cs +[assembly: global::Purview.Telemetry.GeneratedTelemetryNamesAttribute(new string[] { "multiproject-notifications" }, new string[0])] +``` + +Only the console app opts into the aggregated class — see +[`Properties/AssemblyInfo.cs`](SampleApp.MultiProject.ConsoleApp/Properties/AssemblyInfo.cs): + +```csharp +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` + +which produces, in `SampleApp.MultiProject.ConsoleApp.TelemetryNames.g.cs`: + +```csharp +public static readonly string[] MeterNames = new string[] { "SampleApp.MultiProject.Ordering", "multiproject.payments" }; + +public static readonly string[] ActivitySourceNames = new string[] +{ + "SampleApp.MultiProject.Ordering", + "multiproject-host", + "multiproject-notifications", + "multiproject-payments", +}; +``` + +Four assemblies' activity sources and two meters, deduplicated and ordered, resolved entirely at +compile time — no reflection and no assembly scanning, so it is safe under trimming and native AOT. + +## Running + +```bash +just run-mp +# or: dotnet run --project samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp +``` + +```text +=== Purview Telemetry — multi-project aggregation === + +Reference graph: Host -> Ordering -> Payments -> Notifications (all four reference Observability). +Only Ordering is referenced directly by the host. + +TelemetryNames.ActivitySourceNames (4): + - SampleApp.MultiProject.Ordering + - multiproject-host + - multiproject-notifications + - multiproject-payments + +TelemetryNames.MeterNames (2): + - SampleApp.MultiProject.Ordering + - multiproject.payments + +Placing 3 orders: + + [trace ] start multiproject-host/RunningBatch + ord-1001 (£49.99) + [trace ] start SampleApp.MultiProject.Ordering/PlacingOrder + [trace ] start multiproject-payments/ChargingCard + [trace ] start multiproject-notifications/SendingReceipt + [trace ] stop multiproject-notifications/SendingReceipt (0.0 ms) + [metric ] multiproject.payments/payment.payment_duration = 0.5767 + [metric ] multiproject.payments/payment.payments_processed = 1 + [trace ] stop multiproject-payments/ChargingCard (1.8 ms) + [metric ] SampleApp.MultiProject.Ordering/ordering.orders_placed = 1 + [trace ] stop SampleApp.MultiProject.Ordering/PlacingOrder (9.2 ms) +... +``` + +Every line comes from a subscription that `Observability` created from the aggregated arrays alone. + +## What each project demonstrates + +| Project | Telemetry | Points of interest | +| --- | --- | --- | +| `Observability` | none | The `ServiceDefaults` analogue: receives names, owns no telemetry. | +| `Notifications` | tracing | Explicit source name; `EXCLUDE_PURVIEW_TELEMETRY_LOGGING` keeps it free of `Microsoft.Extensions.Logging.Abstractions`. Transitive from the host. | +| `Payments` | tracing + metrics | Explicit source and meter names; counter and histogram; takes `IMeterFactory`. Transitive from the host. | +| `Ordering` | tracing + logging + metrics | One multi-target interface using the default (assembly-derived) names. | +| `ConsoleApp` | tracing | Owns telemetry *and* aggregates; the only project with `GenerateTelemetryNamesClass = true`. | + +Each library also owns its DI registration (`AddNotifications()`, `AddPayments()`, `AddOrdering()`), +chaining to its dependencies and to the generated `Add*Telemetry()` extension — so the host makes a +single `services.AddOrdering()` call for four assemblies' worth of telemetry. + +## Adapting it + +- **Defaults:** `GenerateTelemetryNamesAttribute` is `true` (every assembly publishes its names) and + `GenerateTelemetryNamesClass` is `false` (only the project that registers them generates the + class). Set the former to `false` to keep an assembly's names out of its metadata. +- **Opting out of aggregation:** set `AggregateReferencedTelemetryNames = false` for this assembly's + own names only. +- **Several hosts:** repeat the `[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)]` + in each executable. Libraries never need it. +- **Real OpenTelemetry:** replace `TelemetryDefaults.Listen(...)` with the standard builder calls, + passing the same two arrays: + + ```csharp + builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddSource(TelemetryNames.ActivitySourceNames)) + .WithMetrics(metrics => metrics.AddMeter(TelemetryNames.MeterNames)); + ``` + +## See also + +- [Generation options](../../docs/wiki/Generation.md#telemetry-names) — the attribute and the class +- [`SampleApp`](../SampleApp/README.md) — the full .NET Aspire sample +- [`SampleApp.Net48`](../SampleApp.Net48/README.md) — the same generator on .NET Framework 4.8 diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/IBatchTelemetry.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/IBatchTelemetry.cs new file mode 100644 index 00000000..d33f73ff --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/IBatchTelemetry.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; +using Purview.Telemetry; + +namespace SampleApp.MultiProject.ConsoleApp; + +/// +/// The host owns telemetry too, so the aggregate is its own names plus everything it references. +/// +[ActivitySource("multiproject-host")] +interface IBatchTelemetry +{ + [Activity] + Activity? RunningBatch([Tag] int orderCount); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/Program.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/Program.cs new file mode 100644 index 00000000..b7b627ca --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/Program.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SampleApp.MultiProject.Observability; +using SampleApp.MultiProject.Ordering; + +(string OrderId, decimal Total)[] Orders = [("ord-1001", 49.99m), ("ord-1002", 12.50m), ("ord-1003", 149.00m)]; + +// TelemetryNames is generated for this project only, and already contains the names from +// every assembly in the reference graph. ServiceDefaults-style projects cannot build this +// list themselves, so the host hands it over. +using var subscription = TelemetryDefaults.Listen(TelemetryNames.ActivitySourceNames, TelemetryNames.MeterNames); + +PrintAggregatedNames(); + +ServiceCollection services = new(); +services.AddLogging(logging => logging.AddSimpleConsole(console => console.SingleLine = true)); + +// Registers IMeterFactory, which the generated metric classes take as a constructor parameter. +services.AddMetrics(); + +// One call per direct reference: Ordering registers Payments, which registers Notifications. +services.AddOrdering(); +services.AddBatchTelemetry(); + +using var provider = services.BuildServiceProvider(); + +var orderBook = provider.GetRequiredService(); +var telemetry = provider.GetRequiredService(); + +Console.WriteLine($"Placing {Orders.Length} orders:"); +Console.WriteLine(); + +using (telemetry.RunningBatch(Orders.Length)) +{ + foreach (var (orderId, total) in Orders) + { + Console.WriteLine($" {orderId} ({total:C})"); + orderBook.PlaceOrder(orderId, total); + Console.WriteLine(); + } +} + +static void PrintAggregatedNames() +{ + Console.WriteLine("=== Purview Telemetry — multi-project aggregation ==="); + Console.WriteLine(); + Console.WriteLine( + "Reference graph: Host -> Ordering -> Payments -> Notifications (all four reference Observability)." + ); + Console.WriteLine("Only Ordering is referenced directly by the host."); + Console.WriteLine(); + + Console.WriteLine($"TelemetryNames.ActivitySourceNames ({TelemetryNames.ActivitySourceNames.Length}):"); + foreach (var name in TelemetryNames.ActivitySourceNames) + Console.WriteLine($" - {name}"); + + Console.WriteLine(); + Console.WriteLine($"TelemetryNames.MeterNames ({TelemetryNames.MeterNames.Length}):"); + foreach (var name in TelemetryNames.MeterNames) + Console.WriteLine($" - {name}"); + + Console.WriteLine(); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/Properties/AssemblyInfo.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e3c44d9c --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using Purview.Telemetry; + +// This is the only project in the solution that opts into the TelemetryNames class. Every other +// project records its generated names in assembly metadata automatically, and this one aggregates +// them — its own, its direct references' and its transitive references' — into a single pair of +// arrays. AggregateReferencedTelemetryNames defaults to true, so no further configuration is needed. +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/SampleApp.MultiProject.ConsoleApp.csproj b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/SampleApp.MultiProject.ConsoleApp.csproj new file mode 100644 index 00000000..2cc4e9fb --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.ConsoleApp/SampleApp.MultiProject.ConsoleApp.csproj @@ -0,0 +1,27 @@ + + + Exe + + + + + + + + + + + + + + + + diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/INotificationTelemetry.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/INotificationTelemetry.cs new file mode 100644 index 00000000..2fe2a609 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/INotificationTelemetry.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using Purview.Telemetry; + +namespace SampleApp.MultiProject.Notifications; + +/// +/// Tracing only, with an explicit source name — this library wants nothing to do with logging or +/// metrics. Nothing here registers the name anywhere: the generator records +/// multiproject-notifications in this assembly's metadata and the host picks it up. +/// +/// +/// Only SampleApp.MultiProject.Payments references this project, so the host sees it purely +/// as a transitive reference — the aggregate still includes it. +/// +[ActivitySource("multiproject-notifications")] +public interface INotificationTelemetry +{ + [Activity] + Activity? SendingReceipt([Tag] string orderId); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/NotificationsServiceCollectionExtensions.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/NotificationsServiceCollectionExtensions.cs new file mode 100644 index 00000000..8b3c42d5 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/NotificationsServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SampleApp.MultiProject.Notifications; + +/// +/// The library owns its own registration, including the generated +/// AddNotificationTelemetry() extension (which is to this +/// assembly by default). +/// +public static class NotificationsServiceCollectionExtensions +{ + public static IServiceCollection AddNotifications(this IServiceCollection services) => + services.AddNotificationTelemetry().AddSingleton(); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/ReceiptSender.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/ReceiptSender.cs new file mode 100644 index 00000000..b9947a6b --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/ReceiptSender.cs @@ -0,0 +1,12 @@ +namespace SampleApp.MultiProject.Notifications; + +/// Sends the receipt for a completed order. +public sealed class ReceiptSender(INotificationTelemetry telemetry) +{ + public void SendReceipt(string orderId) + { + using var activity = telemetry.SendingReceipt(orderId); + + // A real implementation would hand the receipt to an email or push provider here. + } +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/SampleApp.MultiProject.Notifications.csproj b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/SampleApp.MultiProject.Notifications.csproj new file mode 100644 index 00000000..3802a8a6 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Notifications/SampleApp.MultiProject.Notifications.csproj @@ -0,0 +1,24 @@ + + + + $(DefineConstants);EXCLUDE_PURVIEW_TELEMETRY_LOGGING + + + + + + + + + + + + diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Observability/SampleApp.MultiProject.Observability.csproj b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Observability/SampleApp.MultiProject.Observability.csproj new file mode 100644 index 00000000..6b512ec9 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Observability/SampleApp.MultiProject.Observability.csproj @@ -0,0 +1 @@ + diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Observability/TelemetryDefaults.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Observability/TelemetryDefaults.cs new file mode 100644 index 00000000..8446a1d1 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Observability/TelemetryDefaults.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace SampleApp.MultiProject.Observability; + +/// +/// Stands in for an Aspire ServiceDefaults project. Every other project in this solution +/// references this one, so its own reference graph contains none of them — it can never discover +/// their telemetry names for itself, no matter what the generator records. The host project, which +/// does reference them, aggregates the names and passes them in here. +/// +/// +/// A real ServiceDefaults would hand the same two arrays to AddSource(...) and +/// AddMeter(...) on the OpenTelemetry tracing and metrics builders. This sample subscribes +/// with and instead, so it stays free of +/// exporter configuration and prints what it receives. +/// +public static class TelemetryDefaults +{ + /// + /// Subscribes to exactly the supplied activity sources and meters, writing everything they + /// produce to the console. Dispose the result to unsubscribe. + /// + public static IDisposable Listen(string[] activitySourceNames, string[] meterNames) + { + ArgumentNullException.ThrowIfNull(activitySourceNames); + ArgumentNullException.ThrowIfNull(meterNames); + + ActivityListener activityListener = new() + { + ShouldListenTo = source => Array.IndexOf(activitySourceNames, source.Name) >= 0, + Sample = static (ref _) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = static activity => + Console.WriteLine($" [trace ] start {activity.Source.Name}/{activity.DisplayName}"), + ActivityStopped = static activity => + Console.WriteLine( + $" [trace ] stop {activity.Source.Name}/{activity.DisplayName} ({activity.Duration.TotalMilliseconds:F1} ms)" + ), + }; + ActivitySource.AddActivityListener(activityListener); + + MeterListener meterListener = new() + { + InstrumentPublished = (instrument, listener) => + { + if (Array.IndexOf(meterNames, instrument.Meter.Name) >= 0) + listener.EnableMeasurementEvents(instrument); + }, + }; + + meterListener.SetMeasurementEventCallback( + static (instrument, measurement, _, _) => WriteMeasurement(instrument, measurement) + ); + meterListener.SetMeasurementEventCallback( + static (instrument, measurement, _, _) => WriteMeasurement(instrument, measurement) + ); + meterListener.SetMeasurementEventCallback( + static (instrument, measurement, _, _) => WriteMeasurement(instrument, measurement) + ); + meterListener.Start(); + + return new Subscription(activityListener, meterListener); + } + + static void WriteMeasurement(Instrument instrument, object measurement) => + Console.WriteLine($" [metric ] {instrument.Meter.Name}/{instrument.Name} = {measurement}"); + + sealed class Subscription(ActivityListener activityListener, MeterListener meterListener) : IDisposable + { + public void Dispose() + { + activityListener.Dispose(); + meterListener.Dispose(); + } + } +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/IOrderingTelemetry.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/IOrderingTelemetry.cs new file mode 100644 index 00000000..c0fc6aa7 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/IOrderingTelemetry.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using Purview.Telemetry; + +namespace SampleApp.MultiProject.Ordering; + +/// +/// All three targets from one interface, using the default names — which resolve to this assembly's +/// name, SampleApp.MultiProject.Ordering, for both the activity source and the meter. The +/// host's aggregate therefore mixes assembly-derived names with the explicit ones chosen by the +/// payments and notifications libraries. +/// +[ActivitySource] +[Logger] +[Meter] +public interface IOrderingTelemetry +{ + [Activity] + Activity? PlacingOrder([Tag] string orderId); + + [Info] + void OrderPlaced(string orderId, decimal total); + + [AutoCounter] + void OrdersPlaced(); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/OrderBook.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/OrderBook.cs new file mode 100644 index 00000000..df3d8f50 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/OrderBook.cs @@ -0,0 +1,17 @@ +using SampleApp.MultiProject.Payments; + +namespace SampleApp.MultiProject.Ordering; + +/// Places an order, charging it through the payments library. +public sealed class OrderBook(IOrderingTelemetry telemetry, PaymentProcessor paymentProcessor) +{ + public void PlaceOrder(string orderId, decimal total) + { + using var activity = telemetry.PlacingOrder(orderId); + + paymentProcessor.Charge(orderId, total); + + telemetry.OrdersPlaced(); + telemetry.OrderPlaced(orderId, total); + } +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/OrderingServiceCollectionExtensions.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/OrderingServiceCollectionExtensions.cs new file mode 100644 index 00000000..85731ef5 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/OrderingServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using SampleApp.MultiProject.Payments; + +namespace SampleApp.MultiProject.Ordering; + +/// +/// Registers this library and everything it depends on. The host calls only this, even though four +/// assemblies' worth of telemetry end up registered. +/// +public static class OrderingServiceCollectionExtensions +{ + public static IServiceCollection AddOrdering(this IServiceCollection services) => + services.AddPayments().AddOrderingTelemetry().AddSingleton(); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/SampleApp.MultiProject.Ordering.csproj b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/SampleApp.MultiProject.Ordering.csproj new file mode 100644 index 00000000..1bd8fd7b --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Ordering/SampleApp.MultiProject.Ordering.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/IPaymentTelemetry.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/IPaymentTelemetry.cs new file mode 100644 index 00000000..fa847d41 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/IPaymentTelemetry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics; +using Purview.Telemetry; + +namespace SampleApp.MultiProject.Payments; + +/// +/// Tracing and metrics with explicit names, and no logging. Both multiproject-payments and +/// the multiproject.payments meter are recorded in this assembly's metadata for the host to +/// aggregate. +/// +[ActivitySource("multiproject-payments")] +[Meter("multiproject.payments")] +public interface IPaymentTelemetry +{ + [Activity] + Activity? ChargingCard([Tag] string orderId, [Tag] decimal amount); + + [Counter] + void PaymentsProcessed([InstrumentMeasurement] int count, [Tag] string currency); + + [Histogram] + void PaymentDuration([InstrumentMeasurement] double milliseconds); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/PaymentProcessor.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/PaymentProcessor.cs new file mode 100644 index 00000000..15c7f80b --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/PaymentProcessor.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; +using SampleApp.MultiProject.Notifications; + +namespace SampleApp.MultiProject.Payments; + +/// Charges the card for an order, then asks the notifications library for a receipt. +public sealed class PaymentProcessor(IPaymentTelemetry telemetry, ReceiptSender receiptSender) +{ + public void Charge(string orderId, decimal amount) + { + using var activity = telemetry.ChargingCard(orderId, amount); + + var timestamp = Stopwatch.GetTimestamp(); + + // A real implementation would call the payment provider here. + receiptSender.SendReceipt(orderId); + + telemetry.PaymentDuration(Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds); + telemetry.PaymentsProcessed(1, "GBP"); + } +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/PaymentsServiceCollectionExtensions.cs b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/PaymentsServiceCollectionExtensions.cs new file mode 100644 index 00000000..0a221cff --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/PaymentsServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using SampleApp.MultiProject.Notifications; + +namespace SampleApp.MultiProject.Payments; + +/// +/// Registers this library and everything it depends on, so the host composes one call per direct +/// reference rather than one per assembly. +/// +public static class PaymentsServiceCollectionExtensions +{ + public static IServiceCollection AddPayments(this IServiceCollection services) => + services.AddNotifications().AddPaymentTelemetry().AddSingleton(); +} diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/SampleApp.MultiProject.Payments.csproj b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/SampleApp.MultiProject.Payments.csproj new file mode 100644 index 00000000..c7fa27c5 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.Payments/SampleApp.MultiProject.Payments.csproj @@ -0,0 +1,26 @@ + + + + $(DefineConstants);EXCLUDE_PURVIEW_TELEMETRY_LOGGING + + + + + + + + + + + + + + diff --git a/samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx b/samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx new file mode 100644 index 00000000..e41fa2d1 --- /dev/null +++ b/samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/samples/SampleApp.Net48/Directory.Build.props b/samples/SampleApp.Net48/Directory.Build.props index 68c3353a..50b3cb66 100644 --- a/samples/SampleApp.Net48/Directory.Build.props +++ b/samples/SampleApp.Net48/Directory.Build.props @@ -13,16 +13,10 @@ for the generator (and analyzer) to see the attribute usages. --> $(DefineConstants);PURVIEW_TELEMETRY_ATTRIBUTES - $(NoWarn);CA1031;CA1303;CA1515;CA1812;CA2007;CA2201;CA5394;CS1574;CS8618;TSG3022; + $(NoWarn);CA1031;CA5394; diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs index f9f9bd8c..f4ea4272 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Program.cs @@ -10,7 +10,7 @@ static class Program { static void Main() { - using var activityListener = new ActivityListener + using ActivityListener activityListener = new() { ShouldListenTo = source => source.Name == TelemetryNames.ActivitySourceNames[0], Sample = (ref options) => ActivitySamplingResult.AllDataAndRecorded, @@ -23,7 +23,7 @@ static void Main() }; ActivitySource.AddActivityListener(activityListener); - using var meterListener = new MeterListener(); + using MeterListener meterListener = new(); meterListener.InstrumentPublished = (instrument, listener) => { if (Array.IndexOf(TelemetryNames.MeterNames, instrument.Meter.Name) >= 0) @@ -39,7 +39,7 @@ static void Main() ); meterListener.Start(); - var services = new ServiceCollection(); + ServiceCollection services = new(); services.AddLogging(logging => logging.AddConsole().SetMinimumLevel(LogLevel.Trace)); diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs index e9128726..561b46cc 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Properties/AssemblyInfo.cs @@ -2,3 +2,6 @@ // Sets the default ActivitySource name used by TelemetryNames.ActivitySourceNames. [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)] diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherService.cs b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherService.cs index 0cbe719e..2aea2e97 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherService.cs +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherService.cs @@ -1,15 +1,15 @@ namespace SampleApp.Net48.ConsoleApp.Services { - public interface IWeatherService + interface IWeatherService { IReadOnlyList GetWeatherForecasts(int requestCount); } - public sealed class WeatherForecast + sealed class WeatherForecast { public System.DateTime Date { get; set; } public int TemperatureC { get; set; } - public string Summary { get; set; } + public string Summary { get; set; } = string.Empty; public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); } } diff --git a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherServiceTelemetry.cs b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherServiceTelemetry.cs index 42b2b299..72797afe 100644 --- a/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherServiceTelemetry.cs +++ b/samples/SampleApp.Net48/SampleApp.Net48.ConsoleApp/Services/IWeatherServiceTelemetry.cs @@ -12,30 +12,30 @@ namespace SampleApp.Net48.ConsoleApp.Services [ActivitySource] [Logger] [Meter] - public interface IWeatherServiceTelemetry + interface IWeatherServiceTelemetry { // --> MULTI-TARGET: Activity + Info log [Activity(ActivityKind.Client)] [Info] - Activity GettingWeatherForecast([Baggage] string requestId, int requestedCount); + Activity? GettingWeatherForecast([Baggage] string requestId, int requestedCount); // --> SINGLE-TARGET: Event [Event] - void ForecastReceived(Activity activity, int minTempInC, int maxTempInC); + void ForecastReceived(Activity? activity, int minTempInC, int maxTempInC); // --> SINGLE-TARGET: Event (Error) [Event(ActivityStatusCode.Error)] - void FailedToRetrieveForecast(Activity activity, Exception ex); + void FailedToRetrieveForecast(Activity? activity, Exception ex); // --> SINGLE-TARGET: Event (Ok) [Event(ActivityStatusCode.Ok)] - void TemperaturesReceived(Activity activity, TimeSpan elapsed); + void TemperaturesReceived(Activity? activity, TimeSpan elapsed); // --> MULTI-TARGET: AutoCounter + Warning log + Event [AutoCounter] [Warning] [Event] - void ItsTooCold(Activity activity, int minTempInC, int tooColdCount); + void ItsTooCold(Activity? activity, int minTempInC, int tooColdCount); // --> SINGLE-TARGET: Histogram [Histogram] diff --git a/samples/SampleApp/README.md b/samples/SampleApp/README.md index 32acc6e3..c91748d4 100644 --- a/samples/SampleApp/README.md +++ b/samples/SampleApp/README.md @@ -150,7 +150,7 @@ public interface IWeatherAPIClientTelemetry 5. **`[ExcludeTargets]`** — exclude a parameter from specific telemetry types in multi-target methods 6. **`[ExpandEnumerable]`** — log individual array/IEnumerable elements as separate properties 7. **DI registration** — generated `Add*Telemetry()` extension methods -8. **`TelemetryNames`** — generated static class with all meter and activity source names for OTel registration +8. **`TelemetryNames`** — opt-in generated static class aggregating this project's meter and activity source names with those of every assembly it references (`SampleApp.Shared` owns and uses its own telemetry, and the hosts pick its names up through the project reference), for OTel registration 9. **Unit testing** — mock telemetry interfaces with NSubstitute; see `SampleApp.APIService.UnitTests` ## Project Structure @@ -163,7 +163,7 @@ SampleApp/ │ ├── Endpoints/ │ │ └── WeatherEndpoints.cs # Minimal API endpoints │ ├── Properties/ -│ │ └── AssemblyInfo.cs # [assembly: ActivitySourceGeneration] +│ │ └── AssemblyInfo.cs # [assembly: TelemetryGeneration] — opts into TelemetryNames │ ├── Services/ │ │ ├── IEntityStoreTelemetry.cs # Reference telemetry interface (Quick Start) │ │ ├── IWeatherService.cs # Business logic interface @@ -179,7 +179,8 @@ SampleApp/ │ │ └── AssemblyInfo.cs │ └── Program.cs ├── SampleApp.ServiceDefaults/ # OpenTelemetry, health checks, resilience -├── SampleApp.Shared/ # WeatherForecast DTO +├── SampleApp.Shared/ # WeatherForecast DTO + library-owned telemetry (IWeatherForecastTelemetry, +│ # consumed by WeatherForecastValidator) └── SampleApp.APIService.UnitTests/ # Unit tests with telemetry mocking ``` @@ -229,6 +230,7 @@ obj/Release/net10.0/generated/Purview.Telemetry.SourceGenerator/Purview.Telemetr SampleApp.APIService.Services.WeatherServiceTelemetryCore.Logging.g.cs SampleApp.APIService.Services.WeatherServiceTelemetryCore.Metric.g.cs SampleApp.APIService.Services.WeatherServiceTelemetryCoreDIExtension.DependencyInjection.g.cs + SampleApp.APIService.GeneratedTelemetryNames.g.cs SampleApp.APIService.TelemetryNames.g.cs ``` @@ -240,10 +242,11 @@ obj/Release/net10.0/generated/Purview.Telemetry.SourceGenerator/Purview.Telemetr SampleApp.Web.Clients.WeatherAPIClientTelemetryCore.Logging.g.cs SampleApp.Web.Clients.WeatherAPIClientTelemetryCore.Metric.g.cs SampleApp.Web.Clients.WeatherAPIClientTelemetryCoreDIExtension.DependencyInjection.g.cs + SampleApp.Web.GeneratedTelemetryNames.g.cs SampleApp.Web.TelemetryNames.g.cs ``` -The `TelemetryNames.g.cs` files expose static arrays used in `Program.cs` to register all sources with OpenTelemetry: +`GeneratedTelemetryNames.g.cs` records the project's names in its assembly metadata — `SampleApp.Shared` gets one of these too, which is how its `IWeatherForecastTelemetry` ActivitySource and Meter (both named `SampleApp.Shared`) reach the hosts. `TelemetryNames.g.cs` is the opt-in aggregate (`[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)]`) whose arrays `Program.cs` passes to OpenTelemetry: ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); diff --git a/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.Success.cs b/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.Success.cs index dd424c32..3b8c6c87 100644 --- a/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.Success.cs +++ b/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.Success.cs @@ -21,4 +21,21 @@ CancellationToken cancellationToken // Assert telemetry.GettingWeatherForecast(Any(), Is(requestCount)).WasCalled(Times.Once); } + + [Test] + public async Task GetWeatherForecastsAsync_GivenRequestCountIsWithinRange_CallsSharedLibraryTelemetry( + CancellationToken cancellationToken + ) + { + // Arrange + const int requestCount = 10; + var forecastTelemetry = CreateForecastTelemetry(); + var service = CreateService(CreateTelemetry(), forecastTelemetry: forecastTelemetry); + + // Act + await service.GetWeatherForecastsAsync(requestCount, cancellationToken); + + // Assert + forecastTelemetry.ForecastsValidated(Is(requestCount)).WasCalled(Times.Once); + } } diff --git a/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.cs b/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.cs index 1d72e80a..ae79b536 100644 --- a/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.cs +++ b/samples/SampleApp/SampleApp.APIService.UnitTests/Services/WeatherServiceTests.cs @@ -2,11 +2,19 @@ public partial class WeatherServiceTests { - static WeatherService CreateService(Mock? telemetry, bool throwOnRNG = false) => + static WeatherService CreateService( + Mock? telemetry, + bool throwOnRNG = false, + Mock? forecastTelemetry = null + ) => new( telemetry: (telemetry ?? Mock.Of()).Object, + // The shared library's validator, with its own (shared-library) telemetry mocked. + validator: new((forecastTelemetry ?? Mock.Of()).Object), rng: () => throwOnRNG ? 8 : 1 // 8 is out magic eight-ball number - it throws randomly in simulated use. ); static Mock CreateTelemetry() => Mock.Of(); + + static Mock CreateForecastTelemetry() => Mock.Of(); } diff --git a/samples/SampleApp/SampleApp.APIService/Program.cs b/samples/SampleApp/SampleApp.APIService/Program.cs index 6f79cb7c..2cfb8efb 100644 --- a/samples/SampleApp/SampleApp.APIService/Program.cs +++ b/samples/SampleApp/SampleApp.APIService/Program.cs @@ -7,6 +7,10 @@ builder.Services.AddScoped().AddWeatherServiceTelemetry(); +// The shared library registers its own services and telemetry; its generated names already arrived +// in TelemetryNames above, through the project reference. +builder.Services.AddWeatherForecastValidation(); + var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/samples/SampleApp/SampleApp.APIService/Properties/AssemblyInfo.cs b/samples/SampleApp/SampleApp.APIService/Properties/AssemblyInfo.cs index 17ea2a97..e7275780 100644 --- a/samples/SampleApp/SampleApp.APIService/Properties/AssemblyInfo.cs +++ b/samples/SampleApp/SampleApp.APIService/Properties/AssemblyInfo.cs @@ -1,4 +1,6 @@ -using Purview.Telemetry; +using Purview.Telemetry; -// Default activity source name for the application. -[assembly: ActivitySourceGeneration("sample-weather-app-api")] +// Generates the TelemetryNames class, aggregating this project's generated names with the names +// recorded by every referenced assembly. It is opt-in: the generated names are always recorded in +// assembly metadata, and only the project that registers them with OpenTelemetry needs the class. +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] diff --git a/samples/SampleApp/SampleApp.APIService/Services/WeatherService.cs b/samples/SampleApp/SampleApp.APIService/Services/WeatherService.cs index 29c107b2..83c78eae 100644 --- a/samples/SampleApp/SampleApp.APIService/Services/WeatherService.cs +++ b/samples/SampleApp/SampleApp.APIService/Services/WeatherService.cs @@ -3,7 +3,11 @@ namespace SampleApp.APIService.Services; -sealed class WeatherService(IWeatherServiceTelemetry telemetry, Func? rng = null) : IWeatherService +sealed class WeatherService( + IWeatherServiceTelemetry telemetry, + WeatherForecastValidator validator, + Func? rng = null +) : IWeatherService { const int TooColdTempInC = -10; @@ -75,22 +79,29 @@ public async Task>> GetWeatherForecastsAsyn }) .ToArray(); - foreach (var wf in results) + // The shared library owns this validation and its own telemetry. This host never registers + // SampleApp.Shared's activity source or meter — they arrive in TelemetryNames through the + // project reference. + var forecasts = validator.Validate(results); + if (forecasts.Count == 0) + return Error.Failure("WeatherForecast.Implausible", "No plausible weather forecasts were produced."); + + foreach (var wf in forecasts) telemetry.HistogramOfTemperature(wf.TemperatureC); - var minTempInC = results.Min(m => m.TemperatureC); + var minTempInC = forecasts.Min(m => m.TemperatureC); // MULTI-TARGET: This single call adds event to activity + logs info - telemetry.ForecastReceived(activity, minTempInC, results.Max(wf => wf.TemperatureC)); + telemetry.ForecastReceived(activity, minTempInC, forecasts.Max(wf => wf.TemperatureC)); if (minTempInC < TooColdTempInC) { // MULTI-TARGET: This single call increments counter + logs warning - telemetry.ItsTooCold(activity, minTempInC, results.Count(wf => wf.TemperatureC < TooColdTempInC)); + telemetry.ItsTooCold(activity, minTempInC, forecasts.Count(wf => wf.TemperatureC < TooColdTempInC)); } else { - telemetry.TemperaturesWithinRange([.. results.Select(m => m.TemperatureC)]); + telemetry.TemperaturesWithinRange([.. forecasts.Select(m => m.TemperatureC)]); } sw.Stop(); @@ -98,7 +109,7 @@ public async Task>> GetWeatherForecastsAsyn // MULTI-TARGET: This single call adds OK event to activity + logs info telemetry.TemperaturesReceived(activity, sw.Elapsed); - return results.ToArray(); + return forecasts.ToArray(); } bool ShouldThrow() diff --git a/samples/SampleApp/SampleApp.Shared/IWeatherForecastTelemetry.cs b/samples/SampleApp/SampleApp.Shared/IWeatherForecastTelemetry.cs new file mode 100644 index 00000000..f7b9d2cf --- /dev/null +++ b/samples/SampleApp/SampleApp.Shared/IWeatherForecastTelemetry.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using Purview.Telemetry; + +namespace SampleApp; + +/// +/// Telemetry owned by the shared library, which has no idea how the host registers telemetry. Both +/// names default to this assembly's name, SampleApp.Shared, so they are easy to spot in the +/// hosts' aggregated TelemetryNames — see the [TelemetryGeneration] in the API service +/// and web projects. +/// +/// +/// consumes this; the names reach the hosts entirely through +/// assembly metadata. +/// +[ActivitySource] +[Meter] +public interface IWeatherForecastTelemetry +{ + [Activity] + Activity? ValidatingForecast([Tag] DateOnly forecastDate); + + [Counter] + void ForecastsValidated([InstrumentMeasurement] int count); +} diff --git a/samples/SampleApp/SampleApp.Shared/SampleApp.Shared.csproj b/samples/SampleApp/SampleApp.Shared/SampleApp.Shared.csproj index 2ef1a363..a0d49e7a 100644 --- a/samples/SampleApp/SampleApp.Shared/SampleApp.Shared.csproj +++ b/samples/SampleApp/SampleApp.Shared/SampleApp.Shared.csproj @@ -1 +1,24 @@ - + + + true + + $(DefineConstants);EXCLUDE_PURVIEW_TELEMETRY_LOGGING + + + + + + + + + + + diff --git a/samples/SampleApp/SampleApp.Shared/WeatherForecastServiceCollectionExtensions.cs b/samples/SampleApp/SampleApp.Shared/WeatherForecastServiceCollectionExtensions.cs new file mode 100644 index 00000000..6f71c45d --- /dev/null +++ b/samples/SampleApp/SampleApp.Shared/WeatherForecastServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SampleApp; + +/// +/// The library owns its own registration, including the generated +/// AddWeatherForecastTelemetry() extension, which is to this +/// assembly. A host calls and gets both. +/// +public static class WeatherForecastServiceCollectionExtensions +{ + public static IServiceCollection AddWeatherForecastValidation(this IServiceCollection services) => + services.AddWeatherForecastTelemetry().AddSingleton(); +} diff --git a/samples/SampleApp/SampleApp.Shared/WeatherForecastValidator.cs b/samples/SampleApp/SampleApp.Shared/WeatherForecastValidator.cs new file mode 100644 index 00000000..89ff2d7a --- /dev/null +++ b/samples/SampleApp/SampleApp.Shared/WeatherForecastValidator.cs @@ -0,0 +1,32 @@ +namespace SampleApp; + +/// +/// Behaviour owned by the shared library, instrumented with the shared library's own telemetry. +/// Hosts call it and never register its activity source or meter themselves: the generator records +/// both names in this assembly's metadata, and each host aggregates them into its own +/// TelemetryNames. +/// +public sealed class WeatherForecastValidator(IWeatherForecastTelemetry telemetry) +{ + const int MinimumPlausibleTemperatureC = -30; + const int MaximumPlausibleTemperatureC = 60; + + /// Returns the forecasts whose temperature is plausible. + public IReadOnlyList Validate(IEnumerable forecasts) + { + ArgumentNullException.ThrowIfNull(forecasts); + + List plausible = []; + foreach (var forecast in forecasts) + { + using var activity = telemetry.ValidatingForecast(forecast.Date); + + if (forecast.TemperatureC is >= MinimumPlausibleTemperatureC and <= MaximumPlausibleTemperatureC) + plausible.Add(forecast); + } + + telemetry.ForecastsValidated(plausible.Count); + + return plausible; + } +} diff --git a/samples/SampleApp/SampleApp.Web/Program.cs b/samples/SampleApp/SampleApp.Web/Program.cs index 7696bb3b..1ef3f67e 100644 --- a/samples/SampleApp/SampleApp.Web/Program.cs +++ b/samples/SampleApp/SampleApp.Web/Program.cs @@ -12,10 +12,9 @@ // Configure typed HttpClient for the API service with resiliency // ServiceDefaults configures standard resilience handler (retries, circuit breaker, timeout) by default builder.Services.AddHttpClient(client => -{ // Use service discovery to resolve the API service URL - client.BaseAddress = new Uri("https+http://api-service"); -}); + client.BaseAddress = new Uri("https+http://api-service") +); builder.Services.AddWeatherAPIClientTelemetry(); diff --git a/samples/SampleApp/SampleApp.Web/Properties/AssemblyInfo.cs b/samples/SampleApp/SampleApp.Web/Properties/AssemblyInfo.cs index 4ee1b171..e7275780 100644 --- a/samples/SampleApp/SampleApp.Web/Properties/AssemblyInfo.cs +++ b/samples/SampleApp/SampleApp.Web/Properties/AssemblyInfo.cs @@ -1,4 +1,6 @@ -using Purview.Telemetry; +using Purview.Telemetry; -// Default activity source name for the application. -[assembly: ActivitySourceGeneration("sample-weather-app-web")] +// Generates the TelemetryNames class, aggregating this project's generated names with the names +// recorded by every referenced assembly. It is opt-in: the generated names are always recorded in +// assembly metadata, and only the project that registers them with OpenTelemetry needs the class. +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] diff --git a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs index d5be5941..8fa0b538 100644 --- a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs @@ -31,6 +31,10 @@ static class GeneratedTypesEmitter ) ); yield return (TypeLibrary.Purview.Telemetry.TelemetryGenerationAttribute, WriteTelemetryGenerationAttribute); + yield return ( + TypeLibrary.Purview.Telemetry.GeneratedTelemetryNamesAttribute, + WriteGeneratedTelemetryNamesAttribute + ); yield return (TypeLibrary.Purview.Telemetry.Targets, WriteTargetsEnum); yield return (TypeLibrary.Purview.Telemetry.NamingConvention, WriteNamingConventionEnum); yield return (TypeLibrary.Purview.Telemetry.ExcludeTargetsAttribute, WriteExcludeTargetsAttribute); @@ -216,7 +220,8 @@ static void EmitAttribute( Action body, string summary, bool wrapInExcludeLoggingGuard = false, - bool includeSuppressMessage = true + bool includeSuppressMessage = true, + bool includeConditional = true ) { #if DEBUG @@ -229,7 +234,8 @@ static void EmitAttribute( : writer.EmptyScope(); var attributes = ImmutableArray.Empty; - attributes = attributes.Add(ConditionalAttribute()); + if (includeConditional) + attributes = attributes.Add(ConditionalAttribute()); if (includeSuppressMessage) attributes = attributes.Add(SuppressMessageAttribute()); @@ -572,11 +578,18 @@ static void WriteTelemetryGenerationAttribute(CodeWriter writer, TypeIdentity ty ); WritePublicProperty( body, - "GenerateTelemetryNamesClass", + "GenerateTelemetryNamesAttribute", TypeLibrary.System.Boolean, - "Determines whether a telemetry names class is generated.", + "Determines whether the assembly-level attribute recording the generated telemetry names is emitted. Defaults to , so downstream assemblies can aggregate the names of everything they reference.", "true" ); + WritePublicProperty( + body, + "GenerateTelemetryNamesClass", + TypeLibrary.System.Boolean, + "Determines whether a telemetry names class is generated. Defaults to ; the generated names are always recorded in assembly metadata, so only the project that registers them with OpenTelemetry needs the class.", + "false" + ); WriteNullableStringProperty( body, "TelemetryNamesClassName", @@ -587,11 +600,73 @@ static void WriteTelemetryGenerationAttribute(CodeWriter writer, TypeIdentity ty "TelemetryNamesNamespace", "The namespace of the generated telemetry names class." ); + WritePublicProperty( + body, + "AggregateReferencedTelemetryNames", + TypeLibrary.System.Boolean, + "Determines whether the generated telemetry names class includes the names generated for referenced assemblies, in addition to this assembly's own names.", + "true" + ); }, "Specifies the telemetry generation behaviour for an interface or assembly." ); } + /// + /// Writes the attribute the generator applies to the assembly, recording the activity source and + /// meter names generated for it. Unlike every other marker attribute this one is deliberately + /// not [Conditional]: the applied attribute has to survive into the assembly's + /// metadata so downstream compilations can read the names back out and aggregate them. + /// + static void WriteGeneratedTelemetryNamesAttribute(CodeWriter writer, TypeIdentity type) + { + var stringArrayType = TypeLibrary.System.String.AsTypeReference().MakeArray(); + + EmitAttribute( + writer, + type, + AttributeTargets.Assembly, + body => + { + body.XmlSummary( + $"Constructs a new instance specifying the {XmlSee("ActivitySourceNames")} and {XmlSee("MeterNames")}." + ) + .XmlParam("activitySourceNames", $"The {XmlSee("ActivitySourceNames")}.") + .XmlParam("meterNames", $"The {XmlSee("MeterNames")}.") + .Constructor( + new(type.Name, TypeDeclarationAccessibility.Public) + { + Parameters = + [ + new("activitySourceNames", stringArrayType), + new("meterNames", stringArrayType), + ], + }, + ctor => + { + ctor.Assignment("ActivitySourceNames", "activitySourceNames"); + ctor.Assignment("MeterNames", "meterNames"); + } + ); + + WritePublicProperty( + body, + "ActivitySourceNames", + stringArrayType, + "Gets the names of the activity sources generated for the assembly." + ); + WritePublicProperty( + body, + "MeterNames", + stringArrayType, + "Gets the names of the meters generated for the assembly." + ); + }, + summary: "Records the activity source and meter names generated for an assembly, so a downstream assembly can aggregate the names of everything it references.", + includeConditional: false + ); + } + static void WriteExcludeTargetsAttribute(CodeWriter writer, TypeIdentity type) { var targets = TypeLibrary.Purview.Telemetry.Targets; diff --git a/src/src/SourceGenerator/Emitters/TelemetryNamesEmitter.cs b/src/src/SourceGenerator/Emitters/TelemetryNamesEmitter.cs index 680063ee..0ce8351a 100644 --- a/src/src/SourceGenerator/Emitters/TelemetryNamesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/TelemetryNamesEmitter.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Records; @@ -6,31 +5,53 @@ namespace Purview.Telemetry.SourceGenerator.Emitters; static class TelemetryNamesEmitter { + /// + /// Emits [assembly: GeneratedTelemetryNames] with the names generated for this assembly. + /// The attribute is the compile-time record downstream assemblies read to build their aggregate. + /// + public static void GenerateAssemblyAttribute(TelemetryNamesOutputContext output, SourceProductionContext spc) + { + output.Context.Debug($"Generating the telemetry names attribute for '{output.AssemblyName}'."); + + var writer = output.CreateWriter(); + + var attributeType = TypeLibrary.Purview.Telemetry.GeneratedTelemetryNamesAttribute.RenderFullName; + var activitySourceNames = BuildAttributeArrayArgument(output.Names.ActivitySourceNames); + var meterNames = BuildAttributeArrayArgument(output.Names.MeterNames); + + writer.Line($"[assembly: {attributeType}({activitySourceNames}, {meterNames})]"); + + var hintName = string.IsNullOrWhiteSpace(output.AssemblyName) + ? "GeneratedTelemetryNames.g.cs" + : $"{output.AssemblyName}.GeneratedTelemetryNames.g.cs"; + + spc.AddSource(hintName, writer); + } + public static void GenerateClass( TelemetryNamesOutputContext output, - ImmutableArray meterNames, - ImmutableArray activitySourceNames, string className, string? rootNamespace, - SourceProductionContext spc, - GenerationContext generationContext + SourceProductionContext spc ) { - generationContext.Debug($"Generating Telemetry Names using class '{className}'."); + output.Context.Debug($"Generating Telemetry Names using class '{className}'."); var writer = output.CreateWriter(); var hasNamespace = !string.IsNullOrWhiteSpace(rootNamespace); using (writer.BlockNamespaceScope(rootNamespace)) { - writer.XmlSummary("Contains the names of the meters and activity sources generated for the assembly."); + writer.XmlSummary( + "Contains the names of the meters and activity sources generated for the assembly, and for the assemblies it references." + ); using ( writer.ClassScope( - new(className) + new(className, TypeDeclarationAccessibility.Internal) { IsStatic = true, IncludeGeneratedAttributes = true, - Attributes = [EmitterHelpers.EditorBrowsableAttribute()], + IncludeEmbeddedAttribute = true, } ) ) @@ -42,7 +63,7 @@ GenerationContext generationContext { IsStatic = true, IsReadOnly = true, - Initializer = BuildArrayInitializer(meterNames), + Initializer = BuildArrayInitializer(output.Names.MeterNames), IncludeGeneratedAttributes = true, } ); @@ -53,7 +74,7 @@ GenerationContext generationContext { IsStatic = true, IsReadOnly = true, - Initializer = BuildArrayInitializer(activitySourceNames), + Initializer = BuildArrayInitializer(output.Names.ActivitySourceNames), IncludeGeneratedAttributes = true, } ); @@ -67,8 +88,17 @@ GenerationContext generationContext spc.AddSource(hintName, writer); } - static string BuildArrayInitializer(ImmutableArray values) => - values.Length == 0 - ? "global::System.Array.Empty()" - : "new string[] { " + string.Join(", ", values.Select(v => "\"" + v + "\"")) + " }"; + /// + /// Builds the array argument for the assembly attribute. Attribute arguments must be constant + /// expressions, so an empty array is written as new string[0] rather than + /// Array.Empty<string>(). + /// + static string BuildAttributeArrayArgument(EquatableArray values) => + values.IsEmpty ? "new string[0]" : BuildArray(values); + + static string BuildArrayInitializer(EquatableArray values) => + values.IsEmpty ? "global::System.Array.Empty()" : BuildArray(values); + + static string BuildArray(EquatableArray values) => + "new string[] { " + string.Join(", ", values.Select(static v => "\"" + v + "\"")) + " }"; } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs index 6e26ceec..6ec945d7 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.TelemetryNames.cs @@ -1,12 +1,27 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Emitters; +using Purview.Telemetry.SourceGenerator.Helpers; using Purview.Telemetry.SourceGenerator.Records; namespace Purview.Telemetry.SourceGenerator; partial class TelemetrySourceGenerator { + /// + /// Registers the two telemetry-names outputs: + /// + /// + /// [assembly: GeneratedTelemetryNames], recording this assembly's names in its metadata. + /// Always emitted, so every assembly publishes what it generated. + /// + /// + /// The TelemetryNames class, opt-in via GenerateTelemetryNamesClass, aggregating this + /// assembly's names with those recorded by every referenced assembly. Typically only the project + /// that registers the names with OpenTelemetry needs it. + /// + /// + /// static void RegisterTelemetryNamesGeneration( IncrementalGeneratorInitializationContext context, IncrementalValuesProvider> activityTargets, @@ -15,136 +30,219 @@ IncrementalValueProvider> generationCon ) { // Extract only AssemblyName from Compilation — a stable string that rarely changes — - // so we don't re-run TelemetryNames generation on every compilation change. + // so we don't re-run the attribute generation on every compilation change. var assemblyNameProvider = context.CompilationProvider.Select(static (c, _) => c.AssemblyName ?? string.Empty); - var outputContext = assemblyNameProvider - .Combine(meterTargets.Collect()) + // The assembly-level [TelemetryGeneration] has to be read independently of the targets: an + // assembly that only consumes telemetry from its references has no target to carry it. + // Projected down to the names-related settings so unrelated changes leave the result cached. + var assemblySettings = context + .CompilationProvider.Select(static (compilation, _) => ResolveAssemblySettings(compilation)) + .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_TelemetryNamesAssemblySettings"); + + // Depends on the target providers and those settings alone, so an unrelated edit leaves it cached. + var resolvedNames = meterTargets + .Collect() .Combine(activityTargets.Collect()) + .Combine(assemblySettings) + .Select(static (tuple, token) => ResolveNames(tuple.Left.Left, tuple.Left.Right, tuple.Right, token)) + .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_TelemetryNamesResolved"); + + var attributeOutput = resolvedNames + .Combine(assemblyNameProvider) + .Combine(generationContext) + .Select( + static (tuple, _) => new TelemetryNamesOutputContext(tuple.Left.Right, tuple.Left.Left, tuple.Right) + ) + .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_TelemetryNamesAttributeOutput"); + + context.RegisterSourceOutput( + source: attributeOutput, + action: static (spc, output) => GenerateTelemetryNamesAttribute(output, spc) + ); + + var classOutput = resolvedNames + .Combine(context.CompilationProvider) .Combine(generationContext) .Select( - static (tuple, _) => - new TelemetryNamesOutputContext( - tuple.Left.Left.Left, - new(tuple.Left.Left.Right), - new(tuple.Left.Right), - tuple.Right - ) + static (tuple, token) => + Aggregate(names: tuple.Left.Left, compilation: tuple.Left.Right, context: tuple.Right, token: token) ) .WithTrackingName($"{nameof(TelemetrySourceGenerator)}_TelemetryNamesOutput"); context.RegisterSourceOutput( - source: outputContext, + source: classOutput, action: static (spc, output) => GenerateTelemetryNames(output, spc) ); } - static void GenerateTelemetryNames(TelemetryNamesOutputContext output, SourceProductionContext spc) + /// + /// Projects the assembly-level [TelemetryGeneration] down to the settings that govern the + /// telemetry names. + /// + static AssemblyTelemetryNamesSettings ResolveAssemblySettings(Compilation compilation) { - if (output.Context.Settings.IsSourceGeneratorDisabled) - { - output.Context.Debug( - $"Telemetry names generation skipped for {output.AssemblyName} because the generator is disabled" - ); - return; - } + var generation = SharedHelpers.GetAssemblyTelemetryGenerationAttribute(compilation); + + return new( + GenerateAttribute: generation.GenerateTelemetryNamesAttribute, + GenerateClass: generation.GenerateTelemetryNamesClass, + AggregateReferencedNames: generation.AggregateReferencedTelemetryNames, + ClassName: generation.TelemetryNamesClassName, + Namespace: generation.TelemetryNamesNamespace + ); + } - // Only generate if we have at least one target - if (output.MeterTargets.IsEmpty && output.ActivityTargets.IsEmpty) + /// + /// Resolves the distinct activity source and meter names generated for this compilation, along + /// with the [TelemetryGeneration] settings that control the TelemetryNames class. + /// + static ResolvedTelemetryNames ResolveNames( + ImmutableArray> meterTargets, + ImmutableArray> activityTargets, + AssemblyTelemetryNamesSettings assemblySettings, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); + + // Only consider targets that are being processed (no interface-level errors). + var processedMeters = meterTargets + .Where(static m => m.ShouldProcess && m.Value is { }) + .Select(static m => m.Value!) + .ToArray(); + var processedActivities = activityTargets + .Where(static m => m.ShouldProcess && m.Value is { }) + .Select(static m => m.Value!) + .ToArray(); + + // The assembly-level attribute seeds the settings — it is the only source when there are no + // targets at all. On top of that: the class is opt-in, so any target asking for it is enough; + // the attribute and the aggregation are opt-out, so any target declining them turns them off. + // The assembly's custom class name/namespace wins, otherwise the first target to name one does. + var generateAttribute = assemblySettings.GenerateAttribute; + var generateClass = assemblySettings.GenerateClass; + var aggregateReferencedNames = assemblySettings.AggregateReferencedNames; + var className = assemblySettings.ClassName; + var classNamespace = assemblySettings.Namespace; + + foreach ( + var generation in processedMeters + .Select(static target => target.TelemetryGeneration) + .Concat(processedActivities.Select(static target => target.TelemetryGeneration)) + ) { - return; + if (!generation.GenerateTelemetryNamesAttribute) + generateAttribute = false; + + if (generation.GenerateTelemetryNamesClass) + generateClass = true; + + if (!generation.AggregateReferencedTelemetryNames) + aggregateReferencedNames = false; + + className ??= generation.TelemetryNamesClassName; + classNamespace ??= generation.TelemetryNamesNamespace; } - // Only consider targets that are being processed (no interface-level errors). - var processedMeters = output - .MeterTargets.Where(m => m.ShouldProcess && m.Value is { }) - .Select(m => m.Value!) + var meterNames = processedMeters + .Where(static target => !string.IsNullOrEmpty(target.MeterName)) + .Select(static target => target.MeterName!) + .Distinct(StringComparer.Ordinal) + .OrderBy(static name => name, StringComparer.Ordinal) .ToImmutableArray(); - var processedActivities = output - .ActivityTargets.Where(m => m.ShouldProcess && m.Value is { }) - .Select(m => m.Value!) + + var activitySourceNames = processedActivities + .Where(static target => !string.IsNullOrEmpty(target.ActivitySourceName)) + .Select(static target => target.ActivitySourceName!) + .Distinct(StringComparer.Ordinal) + .OrderBy(static name => name, StringComparer.Ordinal) .ToImmutableArray(); - // Check if any target has GenerateTelemetryNamesClass set to false - var generateClass = true; - string? customClassName = null; - string? customNamespace = null; + return new( + ActivitySourceNames: new(activitySourceNames), + MeterNames: new(meterNames), + GenerateAttribute: generateAttribute, + GenerateClass: generateClass, + AggregateReferencedNames: aggregateReferencedNames, + ClassName: className, + Namespace: classNamespace + ); + } - // Check meter targets for TelemetryGeneration settings - foreach (var target in processedMeters) - { - if (target!.TelemetryGeneration.GenerateTelemetryNamesClass == false) - { - generateClass = false; - return; - } + /// + /// Merges the names recorded by referenced assemblies into this compilation's own names. The + /// referenced assemblies are only scanned when the aggregated class is actually being generated. + /// + static TelemetryNamesOutputContext Aggregate( + ResolvedTelemetryNames names, + Compilation compilation, + GenerationContext context, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); - if (target.TelemetryGeneration.TelemetryNamesClassName != null && customClassName == null) - { - customClassName = target.TelemetryGeneration.TelemetryNamesClassName; - } + var assemblyName = compilation.AssemblyName ?? string.Empty; + if (!names.GenerateClass || !names.AggregateReferencedNames) + return new(assemblyName, names, context); - if (target.TelemetryGeneration.TelemetryNamesNamespace != null && customNamespace == null) - { - customNamespace = target.TelemetryGeneration.TelemetryNamesNamespace; - } - } + SortedSet activitySourceNames = new(names.ActivitySourceNames, StringComparer.Ordinal); + SortedSet meterNames = new(names.MeterNames, StringComparer.Ordinal); - // Check activity targets for TelemetryGeneration settings - foreach (var target in processedActivities) - { - if (target!.TelemetryGeneration.GenerateTelemetryNamesClass == false) - { - generateClass = false; - return; - } + ReferencedTelemetryNames.Collect(compilation, activitySourceNames, meterNames, token); - if (target.TelemetryGeneration.TelemetryNamesClassName != null && customClassName == null) + return new( + assemblyName, + names with { - customClassName = target.TelemetryGeneration.TelemetryNamesClassName; - } + ActivitySourceNames = new([.. activitySourceNames]), + MeterNames = new([.. meterNames]), + }, + context + ); + } - if (target.TelemetryGeneration.TelemetryNamesNamespace != null && customNamespace == null) - { - customNamespace = target.TelemetryGeneration.TelemetryNamesNamespace; - } + static void GenerateTelemetryNamesAttribute(TelemetryNamesOutputContext output, SourceProductionContext spc) + { + if (output.Context.Settings.IsSourceGeneratorDisabled) + { + output.Context.Debug( + $"Telemetry names attribute generation skipped for {output.AssemblyName} because the generator is disabled" + ); + return; } - if (!generateClass) + if (!output.Names.GenerateAttribute || output.Names.IsEmpty) + return; + + RunSafely(spc, () => TelemetryNamesEmitter.GenerateAssemblyAttribute(output, spc)); + } + + static void GenerateTelemetryNames(TelemetryNamesOutputContext output, SourceProductionContext spc) + { + if (output.Context.Settings.IsSourceGeneratorDisabled) { + output.Context.Debug( + $"Telemetry names generation skipped for {output.AssemblyName} because the generator is disabled" + ); return; } - // Collect unique meter names - var meterNames = processedMeters - .Where(t => !string.IsNullOrEmpty(t.MeterName)) - .Select(t => t!.MeterName!) - .Distinct(StringComparer.Ordinal) - .OrderBy(n => n, StringComparer.Ordinal) - .ToImmutableArray(); - - // Collect unique activity source names - var activitySourceNames = processedActivities - .Where(t => !string.IsNullOrEmpty(t.ActivitySourceName)) - .Select(t => t!.ActivitySourceName!) - .Distinct(StringComparer.Ordinal) - .OrderBy(n => n, StringComparer.Ordinal) - .ToImmutableArray(); + if (!output.Names.GenerateClass || output.Names.IsEmpty) + return; // Use custom class name if provided, otherwise default to "TelemetryNames" - var className = string.IsNullOrWhiteSpace(customClassName) ? "TelemetryNames" : customClassName; + var className = string.IsNullOrWhiteSpace(output.Names.ClassName) ? "TelemetryNames" : output.Names.ClassName!; RunSafely( spc, () => TelemetryNamesEmitter.GenerateClass( output, - meterNames, - activitySourceNames, - className!, - customNamespace ?? output.AssemblyName, - spc, - output.Context + className, + output.Names.Namespace ?? output.AssemblyName, + spc ) ); } diff --git a/src/src/SourceGenerator/Helpers/ReferencedTelemetryNames.cs b/src/src/SourceGenerator/Helpers/ReferencedTelemetryNames.cs new file mode 100644 index 00000000..863fcd8c --- /dev/null +++ b/src/src/SourceGenerator/Helpers/ReferencedTelemetryNames.cs @@ -0,0 +1,99 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.Telemetry.SourceGenerator.Helpers; + +/// +/// Reads the [assembly: GeneratedTelemetryNames] attribute back out of referenced assemblies. +/// The generator applies that attribute to every assembly it generates telemetry for, which makes the +/// names available as compile-time metadata — no reflection, no assembly scanning, so the aggregate +/// survives trimming and native AOT. +/// +static class ReferencedTelemetryNames +{ + static readonly string AttributeName = TypeLibrary.Purview.Telemetry.GeneratedTelemetryNamesAttribute.Name; + static readonly string? AttributeNamespace = TypeLibrary + .Purview + .Telemetry + .GeneratedTelemetryNamesAttribute + .Namespace; + + /// + /// Adds the activity source and meter names recorded by every referenced assembly to the supplied + /// sets. is the flattened reference set the + /// compiler was handed, so transitive references are included as long as the build passes them to + /// the compiler — which is what MSBuild does for project and package references. + /// + public static void Collect( + Compilation compilation, + ISet activitySourceNames, + ISet meterNames, + CancellationToken token + ) + { + foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols) + { + token.ThrowIfCancellationRequested(); + + foreach (var attribute in assembly.GetAttributes()) + { + if (!IsGeneratedTelemetryNamesAttribute(attribute)) + continue; + + AddNames(attribute, constructorIndex: 0, "ActivitySourceNames", activitySourceNames); + AddNames(attribute, constructorIndex: 1, "MeterNames", meterNames); + } + } + } + + /// + /// Matches by name rather than by symbol identity: the attribute is generated as an + /// type into each assembly, so every referenced copy is a distinct + /// symbol. + /// + static bool IsGeneratedTelemetryNamesAttribute(AttributeData attribute) + { + var attributeClass = attribute.AttributeClass; + if (attributeClass is null || !string.Equals(attributeClass.Name, AttributeName, StringComparison.Ordinal)) + return false; + + var containingNamespace = attributeClass.ContainingNamespace; + var @namespace = containingNamespace is { IsGlobalNamespace: false } + ? containingNamespace.ToDisplayString() + : null; + + return string.Equals(@namespace, AttributeNamespace, StringComparison.Ordinal); + } + + /// + /// Reads one of the attribute's string arrays, preferring the constructor argument and falling + /// back to the named property of the same name. + /// + static void AddNames(AttributeData attribute, int constructorIndex, string propertyName, ISet names) + { + var values = + attribute.ConstructorArguments.Length > constructorIndex + ? attribute.ConstructorArguments[constructorIndex] + : default; + + if (values.Kind != TypedConstantKind.Array) + { + foreach (var namedArgument in attribute.NamedArguments) + { + if (!string.Equals(namedArgument.Key, propertyName, StringComparison.Ordinal)) + continue; + + values = namedArgument.Value; + break; + } + } + + if (values.Kind != TypedConstantKind.Array || values.IsNull) + return; + + foreach (var value in values.Values) + { + if (value.Value is string name && !string.IsNullOrWhiteSpace(name)) + names.Add(name); + } + } +} diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.cs index e44b293e..26bbd7f3 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.cs @@ -113,9 +113,11 @@ CancellationToken token DependencyInjectionClassName: null, DependencyInjectionClassIsPublic: false, NamingConvention: 1, // Default to OpenTelemetry - GenerateTelemetryNamesClass: true, + GenerateTelemetryNamesAttribute: true, + GenerateTelemetryNamesClass: false, TelemetryNamesClassName: null, - TelemetryNamesNamespace: null + TelemetryNamesNamespace: null, + AggregateReferencedTelemetryNames: true ); public static TelemetryGenerationAttributeData GetTelemetryGenerationAttribute( @@ -130,6 +132,16 @@ CancellationToken token if (typeData.Exists) return Normalize(typeData); + return GetAssemblyTelemetryGenerationAttribute(compilation); + } + + /// + /// Reads the assembly-level [TelemetryGeneration], falling back to the defaults. The + /// telemetry-names pipeline needs this independently of any target: an assembly that only consumes + /// telemetry from referenced projects has no interface to hang the settings off. + /// + public static TelemetryGenerationAttributeData GetAssemblyTelemetryGenerationAttribute(Compilation compilation) + { var assemblyData = TelemetryGenerationAttributeData.FromAttributeData(compilation.Assembly); return assemblyData.Exists ? Normalize(assemblyData) : DefaultTelemetryGeneration; } diff --git a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs index b66f4f20..49df0298 100644 --- a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs +++ b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.PurviewGenTypes.cs @@ -108,6 +108,9 @@ partial class TypeLibraryGenerator [TypeRef(PurviewTelemetryNamespace, includeInGetTypes: true)] static readonly TypeIdentity TelemetryGenerationAttribute = default; + [TypeRef(PurviewTelemetryNamespace, includeInGetTypes: true)] + static readonly TypeIdentity GeneratedTelemetryNamesAttribute = default; + [TypeRef(PurviewTelemetryNamespace, includeInGetTypes: true)] static readonly TypeIdentity Targets = default; diff --git a/src/src/SourceGenerator/Records/AttributeDataModels.cs b/src/src/SourceGenerator/Records/AttributeDataModels.cs index aaeb49da..e8483c66 100644 --- a/src/src/SourceGenerator/Records/AttributeDataModels.cs +++ b/src/src/SourceGenerator/Records/AttributeDataModels.cs @@ -55,9 +55,11 @@ readonly partial record struct TelemetryGenerationAttributeData( [Argument("dependencyInjectionClassName")] [Property] string? DependencyInjectionClassName, [Property(DefaultValue = false)] bool DependencyInjectionClassIsPublic, [Property(DefaultValue = 1)] int NamingConvention, - [Property(DefaultValue = true)] bool GenerateTelemetryNamesClass, + [Property(DefaultValue = true)] bool GenerateTelemetryNamesAttribute, + [Property(DefaultValue = false)] bool GenerateTelemetryNamesClass, [Property] string? TelemetryNamesClassName, - [Property] string? TelemetryNamesNamespace + [Property] string? TelemetryNamesNamespace, + [Property(DefaultValue = true)] bool AggregateReferencedTelemetryNames ); [Generate("Purview.Telemetry.TagAttribute")] diff --git a/src/src/SourceGenerator/Records/OutputContexts.cs b/src/src/SourceGenerator/Records/OutputContexts.cs index 9f132eda..961d6c57 100644 --- a/src/src/SourceGenerator/Records/OutputContexts.cs +++ b/src/src/SourceGenerator/Records/OutputContexts.cs @@ -23,10 +23,48 @@ readonly record struct MeterOutputContext(MeterTarget Target, GenerationContext< public CodeWriter CreateWriter() => Context.CreateCodeWriter().AutoGeneratedHeader(); } +/// +/// The subset of the assembly-level [TelemetryGeneration] that governs the telemetry names. +/// Resolved straight off the compilation's assembly symbol so that an assembly with no telemetry +/// targets of its own — one that only aggregates the names of its references — can still opt in. +/// Deliberately narrower than : the unrelated settings +/// would needlessly invalidate the cached names when they change. +/// +readonly record struct AssemblyTelemetryNamesSettings( + bool GenerateAttribute, + bool GenerateClass, + bool AggregateReferencedNames, + string? ClassName, + string? Namespace +); + +/// +/// The telemetry names and the [TelemetryGeneration] settings that govern them, resolved from +/// the assembly-level attribute and the activity source and meter targets. Holds nothing but strings +/// and flags, so the pipeline caches on the names themselves rather than on the targets that produced +/// them. +/// +readonly record struct ResolvedTelemetryNames( + EquatableArray ActivitySourceNames, + EquatableArray MeterNames, + bool GenerateAttribute, + bool GenerateClass, + bool AggregateReferencedNames, + string? ClassName, + string? Namespace +) +{ + public bool IsEmpty => ActivitySourceNames.IsEmpty && MeterNames.IsEmpty; +} + +/// +/// Carries the names for one of the two telemetry-names outputs. For the +/// [assembly: GeneratedTelemetryNames] attribute the names are this assembly's only; for the +/// TelemetryNames class they already include the names aggregated from referenced assemblies. +/// readonly record struct TelemetryNamesOutputContext( string AssemblyName, - EquatableArray> MeterTargets, - EquatableArray> ActivityTargets, + ResolvedTelemetryNames Names, GenerationContext Context ) { diff --git a/src/src/SourceGenerator/Sdk/.agents/skills/telemetry-sourcegenerator-quickstart/SKILL.md b/src/src/SourceGenerator/Sdk/.agents/skills/telemetry-sourcegenerator-quickstart/SKILL.md index 0e1c3677..be4de8c4 100644 --- a/src/src/SourceGenerator/Sdk/.agents/skills/telemetry-sourcegenerator-quickstart/SKILL.md +++ b/src/src/SourceGenerator/Sdk/.agents/skills/telemetry-sourcegenerator-quickstart/SKILL.md @@ -121,12 +121,19 @@ public class OrderService(IOrderServiceTelemetry telemetry) ## Register names with OpenTelemetry -The generator also produces a `TelemetryNames` static class containing the meter and activity source names: +The generator records the generated meter and activity source names in every assembly's metadata via `[assembly: GeneratedTelemetryNames]`. In the project that registers them with OpenTelemetry, opt into the `TelemetryNames` class — it aggregates that project's names with the names recorded by every assembly it references: + +```csharp +// Program.cs or Properties/AssemblyInfo.cs of the host project +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] +``` ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` +Libraries need no configuration: they publish their names through the attribute, and the host picks them up. A project referenced *by* the telemetry owners (an Aspire-style `ServiceDefaults`) cannot aggregate them — generate the class in the host and pass the arrays down. + ## Next steps - To migrate existing `ILogger`, `ActivitySource`, or metrics code, use `telemetry-sourcegenerator-migration`. diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNames.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNames.cs new file mode 100644 index 00000000..6ffbd0d3 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNames.cs @@ -0,0 +1,266 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator; + +partial class TelemetrySourceGeneratorTests +{ + const string TelemetryNamesSource = """ + using Purview.Telemetry; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities { + [Activity] + System.Diagnostics.Activity? Activity(string? parentId); + } + + [Meter("testing-meter")] + public interface ITestMetrics { + [Counter] + void Counter([InstrumentMeasurement]int value); + } + """; + + const string AttributeHintName = "TestAssembly.GeneratedTelemetryNames.g.cs"; + const string ClassHintName = "TestAssembly.TelemetryNames.g.cs"; + + /// + /// The default options apply an assembly-level [TelemetryGeneration] to keep the + /// dependency-injection extension off; it is replaced wholesale here because the attribute does + /// not allow multiples. + /// + static TelemetrySourceGeneratorTestOptions TelemetryNamesOptions(string arguments) => + new() + { + AdditionalSources = + [ + $"[assembly: Purview.Telemetry.TelemetryGeneration(GenerateDependencyExtension = false, {arguments})]", + ], + }; + + [Test] + public async Task Generate_GivenTelemetryTargets_RecordsNamesInTheAssemblyAttribute( + CancellationToken cancellationToken + ) + { + // Act + var generationResult = await GenerateAsync(TelemetryNamesSource, cancellationToken: cancellationToken); + + // Assert + var attributeSource = generationResult.GetSource(AttributeHintName); + + await Assert + .That(attributeSource) + .ContainsGeneratedCode("[assembly: global::Purview.Telemetry.GeneratedTelemetryNamesAttribute(") + .Because("the names must be recorded in metadata for downstream assemblies to aggregate"); + await Assert.That(attributeSource).ContainsGeneratedCode("\"testing-activity-source\""); + await Assert.That(attributeSource).ContainsGeneratedCode("\"testing-meter\""); + } + + [Test] + public async Task Generate_GivenNoActivitySourceOrMeter_DoesNotRecordTheAssemblyAttribute( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using Purview.Telemetry; + + namespace Testing; + + [Logger] + public interface ITestLogger { + void Log(int value); + } + """; + + // Act + var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var hintNames = generationResult + .DriverResult.Results.SelectMany(result => result.GeneratedSources) + .Select(generated => generated.HintName); + + await Assert + .That(hintNames) + .DoesNotContain(AttributeHintName) + .Because("logging produces neither an activity source nor a meter name"); + } + + [Test] + public async Task Generate_GivenGenerateTelemetryNamesAttributeFalse_DoesNotRecordTheAssemblyAttribute( + CancellationToken cancellationToken + ) + { + // Act + var generationResult = await GenerateAsync( + TelemetryNamesSource, + TelemetryNamesOptions("GenerateTelemetryNamesAttribute = false"), + cancellationToken: cancellationToken + ); + + // Assert + var hintNames = generationResult + .DriverResult.Results.SelectMany(result => result.GeneratedSources) + .Select(generated => generated.HintName); + + await Assert + .That(hintNames) + .DoesNotContain(AttributeHintName) + .Because("the attribute is emitted by default, but must be suppressible"); + } + + [Test] + public async Task Generate_GivenDefaults_DoesNotGenerateTheTelemetryNamesClass(CancellationToken cancellationToken) + { + // Act + var generationResult = await GenerateAsync(TelemetryNamesSource, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.Generated().HasClass("TelemetryNames", "TestAssembly")) + .IsFalse() + .Because("the class is opt-in; the assembly attribute replaces it by default"); + } + + [Test] + public async Task Generate_GivenGenerateTelemetryNamesClass_GeneratesTheClass(CancellationToken cancellationToken) + { + // Act + var generationResult = await GenerateAsync( + TelemetryNamesSource, + TelemetryNamesOptions("GenerateTelemetryNamesClass = true"), + cancellationToken: cancellationToken + ); + + // Assert + await Assert.That(generationResult.Generated().HasClass("TelemetryNames", "TestAssembly")).IsTrue(); + + var classSource = generationResult.GetSource(ClassHintName); + await Assert.That(classSource).ContainsGeneratedCode("\"testing-activity-source\""); + await Assert.That(classSource).ContainsGeneratedCode("\"testing-meter\""); + } + + [Test] + public async Task Generate_GivenReferencedAssemblyNames_AggregatesThemIntoTheClass( + CancellationToken cancellationToken + ) + { + // Arrange + var options = TelemetryNamesOptions("GenerateTelemetryNamesClass = true") with + { + AdditionalReferences = + [ + TelemetryNamesReference.Create( + "Referenced.Telemetry", + ["referenced-activity-source"], + ["referenced-meter"] + ), + ], + }; + + // Act + var generationResult = await GenerateAsync(TelemetryNamesSource, options, cancellationToken: cancellationToken); + + // Assert + var classSource = generationResult.GetSource(ClassHintName); + + await Assert.That(classSource).ContainsGeneratedCode("\"testing-activity-source\""); + await Assert.That(classSource).ContainsGeneratedCode("\"testing-meter\""); + await Assert + .That(classSource) + .ContainsGeneratedCode("\"referenced-activity-source\"") + .Because("the names recorded by a referenced assembly must be aggregated"); + await Assert.That(classSource).ContainsGeneratedCode("\"referenced-meter\""); + } + + [Test] + public async Task Generate_GivenNoTargetsOfItsOwn_AggregatesReferencedAssemblyNamesIntoTheClass( + CancellationToken cancellationToken + ) + { + // Arrange + // The opt-in lives on the assembly alone: a host project that only wires up the telemetry its + // references generate has no interface of its own to carry [TelemetryGeneration]. + const string noTelemetry = "namespace Testing;"; + var options = TelemetryNamesOptions("GenerateTelemetryNamesClass = true") with + { + AdditionalReferences = + [ + TelemetryNamesReference.Create( + "Referenced.Telemetry", + ["referenced-activity-source"], + ["referenced-meter"] + ), + ], + }; + + // Act + var generationResult = await GenerateAsync(noTelemetry, options, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.Generated().HasClass("TelemetryNames", "TestAssembly")) + .IsTrue() + .Because("the assembly-level opt-in must apply without a local activity source or meter"); + + var classSource = generationResult.GetSource(ClassHintName); + await Assert.That(classSource).ContainsGeneratedCode("\"referenced-activity-source\""); + await Assert.That(classSource).ContainsGeneratedCode("\"referenced-meter\""); + } + + [Test] + public async Task Generate_GivenNoTargetsOfItsOwnAndNoReferencedNames_DoesNotGenerateTheClass( + CancellationToken cancellationToken + ) + { + // Arrange + const string noTelemetry = "namespace Testing;"; + + // Act + var generationResult = await GenerateAsync( + noTelemetry, + TelemetryNamesOptions("GenerateTelemetryNamesClass = true"), + cancellationToken: cancellationToken + ); + + // Assert + await Assert + .That(generationResult.Generated().HasClass("TelemetryNames", "TestAssembly")) + .IsFalse() + .Because("there is nothing to name: neither this assembly nor its references generated any"); + } + + [Test] + public async Task Generate_GivenAggregationDisabled_ExcludesReferencedAssemblyNames( + CancellationToken cancellationToken + ) + { + // Arrange + var options = TelemetryNamesOptions( + "GenerateTelemetryNamesClass = true, AggregateReferencedTelemetryNames = false" + ) with + { + AdditionalReferences = + [ + TelemetryNamesReference.Create( + "Referenced.Telemetry", + ["referenced-activity-source"], + ["referenced-meter"] + ), + ], + }; + + // Act + var generationResult = await GenerateAsync(TelemetryNamesSource, options, cancellationToken: cancellationToken); + + // Assert + var classSource = generationResult.GetSource(ClassHintName); + + await Assert.That(classSource).ContainsGeneratedCode("\"testing-activity-source\""); + await Assert.That(classSource).DoesNotContain("referenced-activity-source"); + await Assert.That(classSource).DoesNotContain("referenced-meter"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNamesNamespace.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNamesNamespace.cs index 76b94147..57412358 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNamesNamespace.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryNamesNamespace.cs @@ -11,7 +11,7 @@ CancellationToken cancellationToken const string source = """ using Purview.Telemetry; - [assembly: TelemetryGeneration(GenerateDependencyExtension = true, TelemetryNamesNamespace = "Custom.Telemetry")] + [assembly: TelemetryGeneration(GenerateDependencyExtension = true, GenerateTelemetryNamesClass = true, TelemetryNamesNamespace = "Custom.Telemetry")] namespace Testing; @@ -56,7 +56,7 @@ await Assert .ContainsGeneratedCode("namespace Custom.Telemetry"); await Assert - .That(generationResult.GetSource("TelemetryNames.g.cs")) + .That(generationResult.GetSource("Custom.Telemetry.TelemetryNames.g.cs")) .ContainsGeneratedCode("namespace Custom.Telemetry"); } diff --git a/src/tests/SourceGenerator.IntegrationTests/Infra/TelemetryNamesReference.cs b/src/tests/SourceGenerator.IntegrationTests/Infra/TelemetryNamesReference.cs new file mode 100644 index 00000000..66bfd1c4 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Infra/TelemetryNamesReference.cs @@ -0,0 +1,83 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace Purview.Telemetry.SourceGenerator.Infra; + +/// +/// Builds a metadata reference that stands in for an assembly built with the generator: it declares +/// the generated attribute as its own type and applies it at the assembly +/// level, exactly as the generator does. Used to exercise the aggregation of referenced assemblies' +/// telemetry names. +/// +public static class TelemetryNamesReference +{ + public static PortableExecutableReference Create( + string assemblyName, + string[] activitySourceNames, + string[] meterNames + ) => Compile(assemblyName, BuildSource(activitySourceNames, meterNames)); + + static string BuildSource(string[] activitySourceNames, string[] meterNames) + { + static string Literal(string[] values) => + "new string[] { " + string.Join(", ", values.Select(value => "\"" + value + "\"")) + " }"; + + var activitySources = Literal(activitySourceNames); + var meters = Literal(meterNames); + + // The attribute is declared exactly as the generator declares it, including + // [Microsoft.CodeAnalysis.Embedded] — Roslyn hides embedded types from name lookup in + // referencing compilations, so the aggregation has to read the attribute data regardless. + return $$""" + [assembly: Purview.Telemetry.GeneratedTelemetryNames({{activitySources}}, {{meters}})] + + namespace Microsoft.CodeAnalysis + { + internal sealed class EmbeddedAttribute : System.Attribute { } + } + + namespace Purview.Telemetry + { + [Microsoft.CodeAnalysis.Embedded] + [System.AttributeUsage(System.AttributeTargets.Assembly)] + internal sealed class GeneratedTelemetryNamesAttribute : System.Attribute + { + public GeneratedTelemetryNamesAttribute(string[] activitySourceNames, string[] meterNames) + { + ActivitySourceNames = activitySourceNames; + MeterNames = meterNames; + } + + public string[] ActivitySourceNames { get; set; } + + public string[] MeterNames { get; set; } + } + } + """; + } + + static PortableExecutableReference Compile(string assemblyName, string source) + { + var compilation = CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(source)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + using MemoryStream stream = new(); + var emitResult = compilation.Emit(stream); + if (!emitResult.Success) + throw new InvalidOperationException(Describe(emitResult)); + + // The stream is left open so that the reference can be used after this method returns. + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + static string Describe(EmitResult emitResult) => + "Failed to compile the referenced telemetry names assembly: " + + string.Join( + Environment.NewLine, + emitResult.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + ); +}