From c552786ab1d13fa9239734682449b584e984b763 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 16 Sep 2026 18:40:30 +0100 Subject: [PATCH] docs: add documentation wiki --- docs/wiki/Activities.md | 173 ++++++++++++ docs/wiki/Breaking-Changes.md | 188 +++++++++++++ docs/wiki/Contributing.md | 83 ++++++ docs/wiki/Diagnostics.md | 122 +++++++++ docs/wiki/FAQ.md | 348 +++++++++++++++++++++++++ docs/wiki/Generated-Output.md | 260 ++++++++++++++++++ docs/wiki/Generation.md | 137 ++++++++++ docs/wiki/Getting-Started.md | 223 ++++++++++++++++ docs/wiki/Home.md | 219 ++++++++++++++++ docs/wiki/Installation.md | 165 ++++++++++++ docs/wiki/Logging-Generation-v1.md | 86 ++++++ docs/wiki/Logging-Generation-v2.md | 89 +++++++ docs/wiki/Logging.md | 76 ++++++ docs/wiki/Metrics.md | 175 +++++++++++++ docs/wiki/Migration-From-Activities.md | 89 +++++++ docs/wiki/Migration-From-ILogger.md | 124 +++++++++ docs/wiki/Multi-Targeting.md | 274 +++++++++++++++++++ docs/wiki/Performance.md | 140 ++++++++++ docs/wiki/Refactorings.md | 113 ++++++++ docs/wiki/Release-Flow.md | 62 +++++ docs/wiki/Sample-Application.md | 160 ++++++++++++ docs/wiki/Tags-and-Baggage.md | 126 +++++++++ docs/wiki/Testing.md | 66 +++++ docs/wiki/_Sidebar.md | 23 ++ 24 files changed, 3521 insertions(+) create mode 100644 docs/wiki/Activities.md create mode 100644 docs/wiki/Breaking-Changes.md create mode 100644 docs/wiki/Contributing.md create mode 100644 docs/wiki/Diagnostics.md create mode 100644 docs/wiki/FAQ.md create mode 100644 docs/wiki/Generated-Output.md create mode 100644 docs/wiki/Generation.md create mode 100644 docs/wiki/Getting-Started.md create mode 100644 docs/wiki/Home.md create mode 100644 docs/wiki/Installation.md create mode 100644 docs/wiki/Logging-Generation-v1.md create mode 100644 docs/wiki/Logging-Generation-v2.md create mode 100644 docs/wiki/Logging.md create mode 100644 docs/wiki/Metrics.md create mode 100644 docs/wiki/Migration-From-Activities.md create mode 100644 docs/wiki/Migration-From-ILogger.md create mode 100644 docs/wiki/Multi-Targeting.md create mode 100644 docs/wiki/Performance.md create mode 100644 docs/wiki/Refactorings.md create mode 100644 docs/wiki/Release-Flow.md create mode 100644 docs/wiki/Sample-Application.md create mode 100644 docs/wiki/Tags-and-Baggage.md create mode 100644 docs/wiki/Testing.md create mode 100644 docs/wiki/_Sidebar.md diff --git a/docs/wiki/Activities.md b/docs/wiki/Activities.md new file mode 100644 index 00000000..b138ffdb --- /dev/null +++ b/docs/wiki/Activities.md @@ -0,0 +1,173 @@ +# Activities + +All activity-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for Activity generation, decorate it with the `[ActivitySource]` attribute. + +> [!IMPORTANT] +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. + +## ActivitySource naming + +In v5 the ActivitySource name defaults to the **assembly name with casing preserved** (OpenTelemetry convention). You can override it at the interface or assembly level: + +- Interface: `ActivitySourceAttribute.Name` +- Assembly: `ActivitySourceGenerationAttribute.Name` + +> [!NOTE] +> `purview` is only used as a fallback when no assembly name is available, in which case the `TSG3001` diagnostic is generated. + +## Activity, Event, or Context + +There are three method types: + +1. **Activity** methods that generate either a started or un-started [`Activity`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity). +2. **Event** methods that generate an [`ActivityEvent`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activityevent) attached to an Activity. +3. **Context** methods that add either tags or baggage to the current Activity. + +> [!TIP] +> Always specify the `Activity` explicitly — otherwise `Activity.Current` is used, which may not be the Activity you expect. When generating an Activity, always return `Activity` or `Activity?`. When using Event and Context methods, always pass in the `Activity` instance returned by an Activity method. + +### Activity + +Decorate the method with the `[Activity]` attribute to explicitly define an Activity method. Parameters can be passed directly to `ActivitySource.CreateActivity` or `ActivitySource.StartActivity`: + +- **tags** — the parameter type must be an [`ActivityTagsCollection`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitytagscollection), `IEnumerable>`, or [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist). +- **parentContext** — the parameter type must be [`ActivityContext`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitycontext). +- **parentId** — the parameter must be named `parentId` with type `string`. +- **links** — the parameter type must be an `IEnumerable`. +- **startTime** — the parameter must be named `startTime` with type `DateTimeOffset`. + +Other parameters can be marked with `[Tag]` or `[Baggage]` to specify where they are applied. The return type must be `void`, `Activity`, or `Activity?`; returning the Activity returns the created or started Activity. + +### Events + +Decorate the method with the `[Event]` attribute to generate a method that creates an `ActivityEvent` and attaches it to the specified `Activity` or `Activity.Current`. To specify an Activity explicitly, the parameter type must be `Activity` or `Activity?`. + +The tags collection can be populated with a parameter of type `ActivityTagsCollection`, `IEnumerable>`, or `TagList`. To specify the `timestamp`, name the parameter `timestamp` with type `DateTimeOffset`. + +The return type must be `void`, `Activity`, or `Activity?`. When returning an Activity, either the one specified as a parameter is used, or `Activity.Current`. + +#### Exceptions + +When an `Exception` parameter is present, the default behaviour follows the [OpenTelemetry exception rules](https://opentelemetry.io/docs/specs/otel/trace/exceptions/): an event named `exception` is added with the following tags: + +- `exception.escaped` — `true` by default; override it by decorating a `bool` parameter with `[Escape]`. +- `exception.message` — the value of `Exception.Message`. +- `exception.stacktrace` — the value of `Exception.StackTrace`. +- `exception.type` — the `Type.FullName` of the exception. + +This behaviour can be overridden with the `EventAttribute` options (see below). + +### Context + +Decorate the method with the `[Context]` attribute to generate a method that populates tags and/or baggage on a specified `Activity` or `Activity.Current`. Parameters are marked with `[Tag]` or `[Baggage]`. The return type must be `void`, `Activity`, or `Activity?`. + +## Inferring method type + +On a **single-target** Activities interface you can skip the method-level attribute: + +- If the method name ends with `Event` (case-sensitive), it is treated as an Event. +- If the first parameter is an `Activity`, it is treated as an Event. +- If the method name ends with `Context` (case-sensitive), it is treated as a Context. +- Anything else defaults to creating an Activity. + +> [!NOTE] +> Inference is disabled on multi-target interfaces — see [Multi-Targeting](Multi-Targeting.md). + +## Inferring tags or baggage + +If you decorate a parameter with `[Tag]` or `[Baggage]`, that stops inference. Any undecorated parameter is treated as a tag or baggage based on the default settings: + +- `ActivitySourceAttribute.DefaultToTags` (interface) — `true` means tags, `false` means baggage. Default `true`. +- `ActivitySourceGenerationAttribute.DefaultToTags` (assembly) — same. Default `true`. + +See also [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md). + +## Attribute reference + +### `[Activity]` + +Defines Activity creation on a method. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string?` | The name of the Activity. If not provided, the method name is used. Available on construction. | +| `Kind` | [`ActivityKind`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitykind) | The kind used to create the Activity. Default `Internal`. Available on construction. | +| `CreateOnly` | `bool` | Whether the created Activity is started or not. Default `false`. When `true`, you must return the `Activity`/`Activity?` from the method. | + +### `[ActivitySource]` + +Defines the creation of the [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource) on an interface. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string?` | The ActivitySource name. If not provided, `ActivitySourceGenerationAttribute.Name` is used, then the assembly name. A warning (`TSG3001`) is generated when no custom name is defined anywhere. Available on construction. | +| `DefaultToTags` | `bool` | Whether undecorated parameters are added as tags (`true`) or baggage (`false`). Special-case parameters are matched first. Default `true`. | +| `BaggageAndTagPrefix` | `string?` | Prefix for tag/baggage names. Useful for grouping. Default `null`. | +| `IncludeActivitySourcePrefix` | `bool` | Whether the `ActivitySourceGenerationAttribute.BaggageAndTagSeparator` is used to generate a prefix. Default `true`. | +| `LowercaseBaggageAndTagKeys` | `bool` | Whether tag/baggage names are lower-cased. Default `true`. | + +### `[ActivitySourceGeneration]` + +Defines ActivitySource defaults at the assembly level. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string` | Default ActivitySource name when none is defined on an interface. | +| `DefaultToTags` | `bool` | Whether undecorated parameters are tags (`true`) or baggage (`false`). Default `true`. | +| `BaggageAndTagPrefix` | `string?` | Prefix for tag/baggage names. Default `null`. | +| `BaggageAndTagSeparator` | `string` | Separator used when generating prefixes. Default `.`. | +| `LowercaseBaggageAndTagKeys` | `bool` | Whether tag/baggage names are lower-cased. Default `true`. | +| `GenerateDiagnosticsForMissingActivity` | `bool` | Whether diagnostics (`TSG3014`/`TSG3015`) are raised when Activity parameters or return values are missing. Default `true`. | + +### `[Baggage]` + +Marks a parameter as baggage on an Activity or Event. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string?` | The baggage name. Defaults to `null`, meaning the parameter name is used. | +| `SkipOnNullOrEmpty` | `bool` | Whether the parameter is skipped when it is `null` or default. Default `false`. | + +### `[Context]` + +Marks a method as adding parameters as tags or baggage to an Activity. There are no properties. + +### `[StatusDescription]` + +Marks a `string` parameter as the status description for an Activity Event — typically used with events that set an error status code. + +```csharp +[ActivitySource("MyApp")] +interface IMyTelemetry +{ + [Event] + void OperationFailed( + Activity? activity, + [StatusDescription]string failureReason + ); +} +``` + +The parameter must be of type `string`, and this attribute is only valid on Event methods, not Activity or Context methods. + +### `[Escape]` + +Marks a `bool` parameter as the escape value on an event-based method. See the [OpenTelemetry exception rules](https://opentelemetry.io/docs/specs/otel/trace/exceptions/). + +### `[Event]` + +Defines a method that creates an `ActivityEvent`. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string?` | The name of the event. If not provided, the method name is used. Available on construction. | +| `StatusCode` | [`ActivityStatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitystatuscode) | Sets the status code on the Activity after the event is added. Default `Unset`. Available on construction. When set to `Error`, the status description is sourced (in order of precedence) from a `[StatusDescription]`-marked parameter, the `StatusDescription` property, the first `Exception` parameter's `Message`, or `null`. | +| `StatusDescription` | `string?` | A static description for the `StatusCode` when set to `Error`. Overridden by a `[StatusDescription]`-marked parameter if present. | +| `UseRecordExceptionRules` | `bool` | Whether the OpenTelemetry exception rules are followed when a parameter is an `Exception`. Default `true`. | +| `RecordExceptionAsEscaped` | `bool` | The value used for `exception.escaped` when `UseRecordExceptionRules` is `true` and an exception is present. Overridable with `[Escape]`. Default `true`. | + +## Next steps + +- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — parameter-level attributes +- [Multi-Targeting](Multi-Targeting.md) — combining Activities with Logging and Metrics +- [Diagnostics](Diagnostics.md) — the `TSG3xxx` Activity rules \ No newline at end of file diff --git a/docs/wiki/Breaking-Changes.md b/docs/wiki/Breaking-Changes.md new file mode 100644 index 00000000..c05eabb1 --- /dev/null +++ b/docs/wiki/Breaking-Changes.md @@ -0,0 +1,188 @@ +# Breaking Changes + +This page documents breaking changes between major versions to help you migrate your code. + +## Table of contents + +- [v3 to v4](#v3-to-v4) + - [Namespace consolidation](#namespace-consolidation) + - [OpenTelemetry-aligned naming](#opentelemetry-aligned-naming) +- [v4 to v5](#v4-to-v5) +- [v1 and v2 to v3](#v1-and-v2-to-v3) + +## v3 to v4 + +Version 4 introduced two major breaking changes: namespace consolidation and OpenTelemetry-aligned naming conventions. + +### Namespace consolidation + +**Impact:** High — requires code changes in all projects using v3 + +v4 consolidates all attributes into a single namespace to simplify imports. + +#### Migration required + +**Before (v3):** + +```csharp +using Purview.Telemetry.Activities; +using Purview.Telemetry.Logging; +using Purview.Telemetry.Metrics; +``` + +**After (v4):** + +```csharp +using Purview.Telemetry; // single namespace +``` + +| v3 namespace | v4 namespace | Affected attributes | +| --- | --- | --- | +| `Purview.Telemetry.Activities` | `Purview.Telemetry` | `[ActivitySource]`, `[Activity]`, `[Event]`, `[Context]`, `[Baggage]` | +| `Purview.Telemetry.Logging` | `Purview.Telemetry` | `[Logger]`, `[Log]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]`, `[Critical]` | +| `Purview.Telemetry.Metrics` | `Purview.Telemetry` | `[Meter]`, `[Counter]`, `[AutoCounter]`, `[Histogram]`, `[Observable*]`, `[UpDownCounter]` | +| `Purview.Telemetry` | `Purview.Telemetry` | `[Tag]`, `[TelemetryGeneration]`, `[Exclude]` | + +#### Migration steps + +1. Replace `using Purview.Telemetry.Activities;` / `using Purview.Telemetry.Logging;` / `using Purview.Telemetry.Metrics;` with `using Purview.Telemetry;`. +2. Remove duplicate imports. +3. Rebuild and test. + +### OpenTelemetry-aligned naming + +**Impact:** Medium to High — changes generated telemetry names (may break dashboards/queries) + +**Introduced in:** v4.0.0-alpha.5 + +**Default behaviour:** Enabled + +v4 defaults to **OpenTelemetry semantic conventions** for generated telemetry names. This is a breaking change if you rely on specific telemetry names in dashboards, queries, or monitoring tools. + +#### What changed + +| Telemetry type | v3 behaviour | v4 default (OpenTelemetry) | Example change | +| --- | --- | --- | --- | +| ActivitySource name | Assembly name lowercased | Assembly name casing preserved | `"myapp"` → `"MyApp"` | +| Activity names | Method name lowercased | Method name casing preserved | `"getentity"` → `"GetEntity"` | +| Tag/baggage keys | Lowercased, smashed compounds | snake_case with underscores | `"entityid"` → `"entity_id"` | +| Metric instrument names | Lowercased, smashed | Hierarchical with meter prefix | `"recordcount"` → `"myapp.products.record.count"` | +| Metric tag keys | Lowercased, smashed | snake_case with underscores | `"requestcount"` → `"request_count"` | + +#### Examples + +**Before (v3):** + +```csharp +[ActivitySource("MyApp")] +interface IOrderTelemetry +{ + [Activity] + Activity? ProcessingOrder([Baggage]int orderId, [Tag]string customerName); +} + +// Generated ActivitySource name: "myapp" +// Generated Activity name: "processingorder" +// Generated tag keys: "orderid", "customername" +``` + +**After (v4+ OpenTelemetry mode — default):** + +```csharp +[ActivitySource("MyApp")] +interface IOrderTelemetry +{ + [Activity] + Activity? ProcessingOrder([Baggage]int orderId, [Tag]string customerName); +} + +// Generated ActivitySource name: "MyApp" +// Generated Activity name: "ProcessingOrder" +// Generated tag keys: "order_id", "customer_name" +``` + +#### Migration options + +**Option 1 — adopt OpenTelemetry naming (recommended):** upgrade and update dashboards/queries to use snake_case keys and hierarchical metric names. + +**Option 2 — revert to v3 legacy naming:** + +```csharp +using Purview.Telemetry; + +// Revert ALL telemetry to v3 naming (assembly-level) +[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] + +// Or set per-interface +[TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] +interface IMyTelemetry { } +``` + +```csharp +public enum NamingConvention +{ + Legacy = 0, // v3 behaviour: lowercase, smashed compounds + OpenTelemetry = 1 // v4+ default: OTel conventions +} +``` + +**Option 3 — mixed mode:** apply different conventions per interface with `[TelemetryGeneration]`. + +> [!TIP] +> For high-impact scenarios, consider using `NamingConvention.Legacy` initially, then gradually migrating to OpenTelemetry conventions during a planned maintenance window. + +## v4 to v5 + +**Impact:** Low to Medium — affects the default meter name + +### Meter default-name resolution + +In v4, when `[Meter(Name = ...)]` was not specified, the meter name defaulted to the interface name without the leading `I` (for example, `ICacheServiceTelemetry` → `CacheServiceTelemetry`). + +In v5 the default meter name is resolved in this order: + +1. `MeterAttribute.Name` (interface) +2. `MeterGenerationAttribute.MeterName` (assembly) +3. The assembly name + +If you rely on the old interface-name meter default, set the name explicitly: + +```csharp +[Meter("CacheServiceTelemetry")] +interface ICacheServiceTelemetry { } +``` + +### `MeterGenerationAttribute` additions + +v5 adds `MeterName` and `MeterNameGenerationType` to `[MeterGeneration]`, controlling the default meter name and whether instrument names are prefixed with the meter name. See [Metrics](Metrics.md#metergeneration). + +### Activity return type should be nullable + +The new `TSG3022` warning recommends returning `Activity?` from Activity methods. It is a warning, not an error, but plan to move to nullable Activity return types as the Activity can be `null` when no listeners are active. + +## v1 and v2 to v3 + +### Logging event-name generation + +Previously, the default event name generated for a logging method included a trimmed-down version of the class name combined with the method name. + +```csharp +[Logger] +interface IServiceTelemetry +{ + void LogAThing(int theThing); +} +``` + +> [!IMPORTANT] +> In v1 and v2 the default event name for `LogAThing` was `Service.LogAThing`. As of v3, the default is `LogAThing`. + +This is supported by changes to `LogPrefixType`: + +| Field | Old behaviour | New behaviour | +| --- | --- | --- | +| `Default` | Generated a prefix based on the generated class name. | Generates no suffix. | +| `NoSuffix` | Generated no suffix. | **Field removed.** | +| `TrimmedClassName` | Previously the default behaviour. | New field; generates a suffix based on the generated class name. | + +To return to the previous behaviour, set `LoggerGenerationAttribute.DefaultPrefixType` to `LogPrefixType.TrimmedClassName` (assembly level) or `LoggerAttribute.PrefixType` to `LogPrefixType.TrimmedClassName` (interface level). \ No newline at end of file diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md new file mode 100644 index 00000000..fd8df4b9 --- /dev/null +++ b/docs/wiki/Contributing.md @@ -0,0 +1,83 @@ +# Contributing + +Contributions are welcome! This page covers development setup, build commands, and the conventions enforced in this repository. + +## Prerequisites + +- .NET 10 SDK (projects target `net10.0`; the source generator targets `netstandard2.0`) +- [Bun](https://bun.sh) for the `package.json`/`.build/*.ts` scripts +- The `Purview.DotNetProjectSdk` MSBuild SDK (pinned in `global.json` under `msbuild-sdks`) +- `csharpier` dotnet tool (pinned in `.config/dotnet-tools.json`) for linting/formatting + +## Clone and set up + +```bash +git clone https://github.com/purview-dev/telemetry-sourcegenerator +cd telemetry-sourcegenerator +``` + +All `just` recipes read the [`Justfile`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/Justfile); configuration defaults to **Debug**. + +## Common commands + +| Command | Purpose | +| --- | --- | +| `just build` | Builds `src/Telemetry.SourceGenerator.slnx` (Debug). | +| `just test` | Runs the integration tests (Debug) with a tree-node filter. | +| `just build-s` | Builds `samples/SampleApp/SampleApp.slnx`. | +| `just test-s` | Runs the sample test solution. | +| `just lint` | Runs `dotnet csharpier check .` (no writes). | +| `just lint-fix` | Runs `dotnet csharpier format .` (writes). | +| `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. | + +## Build and validation workflow + +After making changes to the source generator: + +1. `just build` — build the main solution. +2. `just test` — run the integration tests. +3. `just build-s && just test-s` — build and test the sample application end to end. +4. `just lint` / `just format` — ensure C# formatting compliance before committing. + +> [!NOTE] +> Build and test commands can take a long time (tens of minutes) in slow environments. Give them a generous timeout and never cancel them part-way. + +## Testing + +Integration tests live in `src/tests/SourceGenerator.IntegrationTests` and target `net8.0;net9.0;net10.0`, plus `net48` on Windows only. They use `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit + the framework's `CodeQuery` syntax-lookup API and assertion extensions). There is no Verify/snapshot library; refactoring tests use the framework's `CodeRefactoringTestBase` snapshot approach. + +When generator behaviour changes, add or update tests in `src/tests/SourceGenerator.IntegrationTests/`. + +## Conventions + +- **Conventional commits** — enforced by commitlint (`.config/lefthook.yml`). Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. +- **Formatting** — C# is formatted with csharpier; check with `just lint`. +- **Solutions** — `.slnx` solution files are used. +- **Source generator** — targets `netstandard2.0` and uses `Purview.SourceGeneratorFramework` (CodeWriter-based emission, incremental pipeline, value-equatable models). + +## Pipelines + +The repo mirrors its GitHub CI/CD pipelines locally with the reusable `purview-build` tool: + +| Command | Purpose | +| --- | --- | +| `just pipeline-pr` | Restore, build, lint, and tests (the PR gate). | +| `just pipeline-build` | Restore, build, lint (no tests). | +| `just pipeline-tests` | Build with tests enabled. | +| `just pipeline-release` | Full release: build, test, pack, publish, GitHub release. | +| `just pipeline-local-release` | Pack + publish to a local NuGet feed (see the Justfile note on argument quoting). | + +## Source-generator skills + +Before doing specialist work, load the relevant skill from `.agents/skills/` — see `AGENTS.md` ("Source-generator and testing skills") for the full list and when to load each one. + +## Release process + +See [`docs/release-process.md`](../release-process.md) for the full flow. In short: push a feature branch and open a PR to `main` (`pr.yml` runs build + lint + tests); merging to `main` triggers the reusable release pipeline (build, test, pack, publish, GitHub release). The version lives in `package.json`; after bumping it, run `just update-version` to sync docs/samples, then `just pipeline-local-release` to validate locally. + +## Next steps + +- [Release Flow](Release-Flow.md) — how releases are produced +- [Testing](Testing.md) — mocking generated telemetry interfaces \ No newline at end of file diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md new file mode 100644 index 00000000..e3d1a1ec --- /dev/null +++ b/docs/wiki/Diagnostics.md @@ -0,0 +1,122 @@ +# Diagnostics + +The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which validates telemetry interfaces and raises diagnostics with the `TSG` prefix. All diagnostics are grouped by category. + +- **General** — `TSG1xxx` +- **Logging** — `TSG2xxx` +- **Activities** — `TSG3xxx` +- **Metrics** — `TSG4xxx` + +## General diagnostics (TSG1xxx) + +| ID | Severity | Description | +| --- | --- | --- | +| `TSG1000` | Error | Fatal execution error occurred (`Failed to execute the generation stage: {0}`). Raised by the generator itself when an unexpected exception escapes the pipeline. | +| `TSG1001` | Error | Inferring generation targets is not supported when using multi-target generation. A method on a multi-target interface has no explicit generation attribute. | +| `TSG1002` | Error | Multiple attributes from the same target family are not supported. Only one Activity, Logging, or Metrics attribute is allowed per method. | +| `TSG1003` | Error | Duplicate method names are not supported. Two or more methods on the interface share the same name. | +| `TSG1004` | Error | Generic interfaces are not supported. | +| `TSG1005` | Error | Generic methods are not supported. | +| `TSG1006` | Warning | `ExcludeTargets` references a target not present on this method. | +| `TSG1007` | Warning | `ExcludeTargets` results in an empty or invalid parameter set for a target. | +| `TSG1008` | Warning | Activity parameter has no Activity target. A parameter of type `Activity` is present on a method with no `[Activity]`/`[Event]`/`[Context]` attribute. | +| `TSG1010` | Error | Method target not registered on interface. A method carries an attribute for a target family that is not registered on the interface. | +| `TSG1011` | Error | Unsupported target framework. The compilation targets neither .NET 8+ nor .NET Framework 4.8+; define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | + +## Logging diagnostics (TSG2xxx) + +| ID | Severity | Description | +| --- | --- | --- | +| `TSG2000` | Error | Too many exception parameters. A non-scoped log method has more than one `Exception`-typed parameter. | +| `TSG2001` | Error | More than 6 parameters. A log method in v1 generation mode has more than 6 non-exception parameters. | +| `TSG2002` | Info | Inferring error log level. In v1 generation, a single `Exception` parameter is present with no explicit level, so `Error` is inferred. | +| `TSG2003` | Warning | Could not find a reference to `Microsoft.Extensions.Logging.ILogger`, skipping log generation. | +| `TSG2004` | Error | Cannot mix ordinal and named property placeholders in a message template. | +| `TSG2005` | Error | Ordinal values exceed parameter count. The maximum ordinal placeholder value exceeds the number of provided parameters. | +| `TSG2006` | Error | Using `[LogProperties]` and `[ExpandEnumerable]` on the same parameter is not supported. | +| `TSG2007` | Warning | A scoped log shouldn't have a `LogLevel`; it will be ignored. | +| `TSG2008` | Warning | Unbounded enumeration possible. `[ExpandEnumerable]` has a `MaximumValueCount` greater than the recommended default of 5. | +| `TSG2021` | Error | Log method must return void or IDisposable. (Returning `Activity` is allowed when the method is also an Activity method.) | + +## Activities diagnostics (TSG3xxx) + +| ID | Severity | Description | +| --- | --- | --- | +| `TSG3000` | Warning | Baggage parameter types only accept strings (`ToString()` will be called). | +| `TSG3001` | Warning | No activity source specified. Generation defaults to `purview` when no name is available anywhere. | +| `TSG3002` | Error | Invalid return type. An Activity/Event method returns something other than `void` or `System.Diagnostics.Activity`. | +| `TSG3003` | Error | Duplicate reserved parameters defined. More than one parameter maps to the same reserved destination. | +| `TSG3004` | Error | Activity parameter is not valid. An `Activity`-destination parameter appears on an Activity method (only valid on `[Event]` methods). | +| `TSG3005` | Error | Timestamp parameter is not valid. A `timestamp` parameter appears on a method that is not an `[Event]` method. | +| `TSG3006` | Error | Start time parameter is not valid on Create activity or Event method. | +| `TSG3007` | Error | Parent context or Parent Id parameter is not valid on event. | +| `TSG3008` | Error | Activity links parameters are not valid on events or context methods. | +| `TSG3009` | Error | Activity tags parameter is not valid on context methods. | +| `TSG3010` | Error | Escaped parameters must be a boolean. | +| `TSG3011` | Error | Escaped parameters are only valid on Events, not Activity or Context methods. | +| `TSG3012` | Info | There are no Activity methods defined, assumed use of `Activity.Current`. | +| `TSG3013` | Warning | Should return the created Activity. An Activity method does not return the created `Activity`. | +| `TSG3014` | Warning | Should accept an Activity to apply the Event/Tags/Baggage to. An Event/Context method has no `Activity` parameter. Opt-in via `ActivitySourceGeneration.GenerateDiagnosticsForMissingActivity` (default `true`). | +| `TSG3015` | Info | Activity should be the first parameter. Opt-in via `GenerateDiagnosticsForMissingActivity`. | +| `TSG3016` | Error | Status description parameter should be a string. | +| `TSG3017` | Error | Status Description parameters are only valid on Events, not Activity or Context methods. | +| `TSG3021` | Info | Exception event does not use OpenTelemetry standard name. An `[Event]` method records an exception but the event name is not the standard `"exception"` (suggest `[Event(Name = "exception")]`). | +| `TSG3022` | Warning | Activity return type should be nullable. An Activity method returns non-nullable `Activity`; use `Activity?` because the Activity can be null when no listeners are active. | + +## Metrics diagnostics (TSG4xxx) + +| ID | Severity | Description | +| --- | --- | --- | +| `TSG4000` | Error | No instrument defined. A method on a Metrics interface has no instrument attribute and is not excluded. | +| `TSG4001` | Error | Must return void or bool. A metrics-owned method returns something other than `void` or `bool`. | +| `TSG4002` | Error | Auto increment counter and measurement defined. An auto-increment instrument also has a measurement parameter. | +| `TSG4003` | Error | Multiple measurement values defined. | +| `TSG4004` | Error | No measurement value defined. A non-auto-increment instrument method has no measurement parameter. | +| `TSG4005` | Error | Observable instrument requires `Func`. | +| `TSG4006` | Error | Invalid measurement type. Not one of `byte`, `short`, `int`, `long`, `double`, `float`, `decimal`, `Measurement`, or `IEnumerable>`. | +| `TSG4007` | Error | Observable metrics cannot return bool. | +| `TSG4008` | Error | AutoCounter must return void. | +| `TSG4009` | Warning | Instrument name matches the instrument type name. Use a name that describes what is measured. | + +## Common resolutions + +### TSG1001 — inference disabled on multi-target interfaces + +A method on a multi-target interface (`[ActivitySource]` + `[Logger]` + `[Meter]`) has no explicit attribute. Add the attributes for each target the method should emit, or mark it `[Exclude]`. + +```csharp +[ActivitySource("MyApp")] +[Logger] +[Meter] +interface IMyTelemetry +{ + [Info] // ✅ explicit target + void ProcessItem(int id); +} +``` + +### TSG1003 — duplicate method names + +Two or more methods share the same name, which is used to generate members on the implementation class. Rename the methods. + +### TSG2008 — unbounded enumeration + +`[ExpandEnumerable]` is configured with a `MaximumValueCount` greater than 5. Reduce it to the recommended default (5) unless you have tested the performance impact. + +### TSG3013 / TSG3014 — missing Activity + +An Activity method does not return the created `Activity`, or an Event/Context method has no `Activity` parameter. Return the `Activity`/`Activity?` and pass it to Event/Context methods. These best-practice diagnostics are controlled by `ActivitySourceGeneration.GenerateDiagnosticsForMissingActivity`. + +### TSG3022 — non-nullable Activity return + +Return `Activity?` so callers can handle the `null` case when no listeners are active. + +### TSG4002 — auto-increment counter with a measurement + +`[AutoCounter]` (or `[Counter(AutoIncrement = true)]`) must not declare a measurement parameter — the measurement is fixed to 1. + +## See also + +- [Getting Started](Getting-Started.md) +- [Activities](Activities.md), [Logging](Logging.md), [Metrics](Metrics.md) +- [Multi-Targeting](Multi-Targeting.md) \ No newline at end of file diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md new file mode 100644 index 00000000..103ffecf --- /dev/null +++ b/docs/wiki/FAQ.md @@ -0,0 +1,348 @@ +# Frequently Asked Questions (FAQ) + +Common questions and answers about the Purview Telemetry Source Generator. + +## General questions + +### What is the Purview Telemetry Source Generator? + +A .NET incremental source generator that produces implementation code for Activities (distributed tracing), Logging (structured logs), and Metrics from interface definitions you create. Instead of writing boilerplate telemetry code, you define methods on an interface and the generator creates the implementation, DI registration helpers, and multi-target generation. + +### Why use a source generator for telemetry? + +- **Zero boilerplate** — no manual implementation code to write or maintain +- **Type safety** — compile-time validation of telemetry code +- **Testability** — easy to mock interfaces in unit tests +- **Consistency** — all telemetry follows the same patterns +- **DI-ready** — automatic dependency injection registration +- **Performance** — generated code is optimized +- **Maintainability** — changes to interfaces propagate automatically + +### What .NET versions are supported? + +- .NET 8 or higher +- .NET Framework 4.8 or higher + +### Is this compatible with OpenTelemetry? + +Yes. v4+ defaults to OpenTelemetry semantic conventions for generated names. The generated Activities, Logs, and Metrics work with OpenTelemetry exporters and collectors. + +## Installation & setup + +### How do I install the package? + +Add the NuGet package to your `.csproj`: + +```xml + + all + analyzers + +``` + +See [Getting Started](Getting-Started.md) for more details. + +### Why do I need `PrivateAssets` and `IncludeAssets`? + +- `PrivateAssets="all"` — prevents the package from being exposed to consuming projects +- `IncludeAssets="analyzers"` — includes only what is needed for source generation + +### Do I need any other packages? + +Depends on what you generate: + +- **Activities** — no additional packages (uses `System.Diagnostics.DiagnosticSource`) +- **Logging** — `Microsoft.Extensions.Logging.Abstractions`; optionally `Microsoft.Extensions.Telemetry.Abstractions` for v2 features +- **Metrics** — no additional packages (uses `System.Diagnostics.Metrics`) +- **DI** — `Microsoft.Extensions.DependencyInjection.Abstractions` (usually already present) + +### How do I view the generated code? + +```xml + + true + +``` + +Generated files appear in `obj/Debug|Release//generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. + +## Activities + +### When should I use Activities vs Logging vs Metrics? + +- **Activities** — distributed operations across services, end-to-end latency, trace spans +- **Logging** — events, state changes, debugging with structured data, error details +- **Metrics** — counting occurrences, distributions, gauges for dashboards + +**Pro tip:** use [Multi-Targeting](Multi-Targeting.md) to combine all three. + +### Why does my Activity method return `Activity?` instead of `Activity`? + +Activities can be `null` when no listeners are subscribed to the ActivitySource or sampling determines the activity should not be recorded. Always return `Activity?` and guard against `null`. + +### Should I use `Activity.Current` or pass Activity parameters? + +**Always pass Activity parameters explicitly.** `Activity.Current` may not be the activity you expect, especially in async code or with nested activities. + +```csharp +// Good +[Event] +void OrderProcessed(Activity? activity, int orderId); + +// Avoid +[Event] +void OrderProcessed(int orderId); // uses Activity.Current implicitly +``` + +### What's the difference between `[Tag]` and `[Baggage]`? + +- **`[Tag]`** — added as tags to the Activity or ActivityEvent; recorded with the activity but not automatically propagated to child activities +- **`[Baggage]`** — added as baggage; automatically propagated to child activities and across service boundaries + +Use baggage sparingly as it increases overhead; use tags for most properties. + +## Logging + +### What's the difference between Generation v1 and v2? + +See [Logging](Logging.md) for the full comparison. In brief: + +- **Generation v2** — state-based output resembling the built-in `[LoggerMessage]` generator; supports dynamic message templates, `[ExpandEnumerable]`, and `[LogProperties]`; requires `Microsoft.Extensions.Telemetry.Abstractions`. +- **Generation v1** — `LoggerMessage.Define` high-performance mode; limited to 6 non-exception parameters plus one `Exception`; no `[ExpandEnumerable]`/`[LogProperties]`. + +The default `LoggerGenerationMode.Auto` selects the best mode **per method**. + +### How do I create scoped logs? + +Return `IDisposable?` from a log method: + +```csharp +[Logger] +interface IOrderTelemetry +{ + [Info] + IDisposable? ProcessingOrder(Guid orderId); +} + +using (telemetry.ProcessingOrder(orderId)) +{ + // Duration logged automatically when disposed +} +``` + +### Can I customize log message templates? + +Yes — `[Log].MessageTemplate` sets the template. Placeholders map to method parameters: + +```csharp +[Info("Order {OrderId} placed for {CustomerName}")] +void OrderPlaced(int orderId, string customerName); +``` + +If no template is specified, one is generated from the method name and parameters. + +### How do I disable logging generation? + +```xml + + EXCLUDE_PURVIEW_TELEMETRY_LOGGING + +``` + +Useful when `Microsoft.Extensions.Logging` types are not available. + +## Metrics + +### What's the difference between Counter and AutoCounter? + +- **`[Counter]`** — you specify the measurement value: + + ```csharp + [Counter] + void ItemsProcessed([InstrumentMeasurement]int count, [Tag]string type); + ``` + +- **`[AutoCounter]`** — automatically increments by 1 per call: + + ```csharp + [AutoCounter] + void ItemProcessed([Tag]string type); + ``` + +### When should I use Histogram vs Counter? + +- **Counter** — discrete events that only go up (requests, errors, completions) +- **Histogram** — distributions where percentiles matter (latency, size, duration) + +### What are observable metrics and when should I use them? + +Observable instruments (`[ObservableCounter]`, `[ObservableGauge]`, `[ObservableUpDownCounter]`) are pull-based — the collector calls your `Func` to get the current value. Use them for values that already exist (memory usage, queue depth, cache size) or expensive calculations you don't want to run on every update. + +```csharp +[ObservableGauge] +void QueueDepth(Func measurement); + +telemetry.QueueDepth(() => _queue.Count); +``` + +### When do metric names include the meter name? + +Instrument names are generated with the meter name as a lowercase dot-separated prefix when using `NamingConvention.OpenTelemetry` combined with `MeterNameGenerationType.OpenTelemetry`: + +```csharp +[Meter("MyApp.Orders")] +interface IOrderMetrics +{ + [Counter] + void OrderProcessed([InstrumentMeasurement]int count); +} + +// Generated name: "myapp.orders.order.processed" +// ^^^^^^^^^^^^^^^ meter prefix (lowercase) +// ^^^^^^^^^^^^^^^^ instrument name +``` + +The default `MeterNameGenerationType.DotNet` does not add the meter-name prefix. See [Metrics](Metrics.md#meter-naming). + +## Multi-targeting + +### Can I generate Activities, Logging, AND Metrics from one interface? + +Yes — this is called [Multi-Targeting](Multi-Targeting.md): + +```csharp +[ActivitySource("MyApp")] +[Logger] +[Meter("MyApp")] +interface IMyTelemetry +{ + [Activity] + [Info] + [AutoCounter] + Activity? ProcessingRequest([Baggage]string requestId); +} +``` + +### What's the difference between single-target and multi-target interfaces? + +- **Single-target** — one class-level attribute; method-level inference is supported. +- **Multi-target** — multiple class-level attributes; every method must declare its targets explicitly (no inference). + +## Configuration & generation + +### How do I control the generated class name? + +```csharp +[TelemetryGeneration(ClassName = "MyCustomTelemetry")] +[ActivitySource("MyApp")] +interface IMyTelemetry { } +``` + +### How do I disable dependency injection generation? + +```csharp +[TelemetryGeneration(GenerateDependencyExtension = false)] +[ActivitySource("MyApp")] +interface IMyTelemetry { } +``` + +### How do I exclude a method from generation? + +Use `[Exclude]` and implement it manually in a partial class: + +```csharp +[ActivitySource("MyApp")] +interface IMyTelemetry +{ + [Activity] + Activity? NormalMethod(int id); + + [Exclude] + void CustomMethod(int id); +} + +partial class MyTelemetryCore +{ + public void CustomMethod(int id) + { + // your custom implementation + } +} +``` + +## Migration + +### What are the breaking changes in v4 and v5? + +Two major v4 changes — **namespace consolidation** into `Purview.Telemetry` and **OpenTelemetry naming** — plus v5 changes including the meter default-name resolution. See [Breaking Changes](Breaking-Changes.md). + +### How do I keep v3 naming? + +```csharp +[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] +``` + +See [OpenTelemetry-Aligned Naming](Breaking-Changes.md#opentelemetry-aligned-naming). + +### Can I use v4 alongside v3? + +Not in the same project. In a multi-project solution different projects can use different versions, though that is not recommended. + +## Troubleshooting + +### The generator isn't producing any code + +1. The interface has a class-level attribute (`[ActivitySource]`, `[Logger]`, or `[Meter]`) +2. Methods have appropriate method-level attributes +3. The interface is not generic (generics are not supported) +4. Rebuild the project to trigger generation +5. Check the Error List for `TSG` diagnostics + +### I'm getting TSG diagnostic errors + +See [Diagnostics](Diagnostics.md) for the complete list of codes and meanings. + +### Generated code doesn't compile + +Common causes: missing packages (`Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Telemetry.Abstractions` for v2), wrong parameter types, incorrect attribute usage, or generic interfaces/methods. + +### Can I use async methods? + +The generated methods are synchronous, but your calling code can be async — telemetry operations are lightweight and non-blocking. + +## Performance + +### Does using a source generator impact performance? + +No runtime impact — the code is generated at compile time. The generated code is as fast as hand-written telemetry with minimal allocations. See [Performance](Performance.md). + +## Advanced topics + +### Can I customize the generated code? + +Not directly, but you can use `[Exclude]` with partial-class implementations, control naming via `[TelemetryGeneration]`, and configure defaults through the assembly-level generation attributes. + +### Does this work with .NET Native AOT? + +The source generator itself works with AOT. Generated code uses no reflection and is trimmable; validate your full scenario with AOT analysis enabled. + +### Can I use this in a library? + +Yes. Generated implementation and DI classes are internal by default, so they don't leak to consumers. Make the DI class public with `[TelemetryGeneration(DependencyInjectionClassIsPublic = true)]` if you need public registration helpers. + +### How do I test code that uses telemetry interfaces? + +Just mock the interface — see [Testing](Testing.md): + +```csharp +var mockTelemetry = Substitute.For(); +var service = new OrderService(mockTelemetry); +``` + +## Getting help + +- **Documentation** — [purview.dev](https://purview.dev/docs/telemetry-sourcegenerator/) +- **Issues** — [GitHub Issues](https://github.com/purview-dev/telemetry-sourcegenerator/issues) +- **Sample** — [Sample Application](Sample-Application.md) + +When reporting a bug, include the package version, .NET version, a minimal repro, expected vs actual behaviour, and any `TSG` diagnostics. \ No newline at end of file diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/Generated-Output.md new file mode 100644 index 00000000..4e886b21 --- /dev/null +++ b/docs/wiki/Generated-Output.md @@ -0,0 +1,260 @@ +# Generated Output + +This page shows real generated output from the [sample application](Sample-Application.md), produced by `5.0.0-prerelease.8`. The interface: + +```csharp +[ActivitySource] +[Logger] +[Meter] +interface IEntityStoreTelemetry +{ + [Activity] + [Info] + [AutoCounter] + Activity? GettingEntityFromStore(int entityId, [Baggage] string serviceUrl); + + [Event] + [Trace] + void GetDuration(Activity? activity, int durationInMS); + + [Context] + void RetrievedEntity(Activity? activity, float totalValue, int lastUpdatedByUserId); + + [Warning] + void EntityNotFound(int entityId); + + [Histogram] + void RecordEntitySize(int sizeInBytes); +} +``` + +generates one partial `EntityStoreTelemetryCore` class split across Activity, Logging, and Metric files, plus a DI extension class and an assembly-level `TelemetryNames` class. + +> [!NOTE] +> To inspect the generated output in your own project, enable `EmitCompilerGeneratedFiles` and look under `obj///generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. + +## Activities (`EntityStoreTelemetryCore.Activity.g.cs`) + +```csharp +// +#nullable enable + +[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] +internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry +{ + private static readonly global::System.Diagnostics.ActivitySource _activitySource = new global::System.Diagnostics.ActivitySource("sample-weather-app-api"); + + public static void RecordExceptionInternal( + global::System.Diagnostics.Activity? activity, + global::System.Exception? exception, + bool escape + ) + { + if (activity == null || exception == null) + { + return; + } + + global::System.Diagnostics.ActivityTagsCollection tagsCollection = new global::System.Diagnostics.ActivityTagsCollection(); + tagsCollection.Add("exception.escaped", escape); + tagsCollection.Add("exception.message", exception.Message); + tagsCollection.Add("exception.type", exception.GetType().FullName); + tagsCollection.Add("exception.stacktrace", exception.StackTrace); + + global::System.Diagnostics.ActivityEvent recordExceptionEvent = new global::System.Diagnostics.ActivityEvent(name: "exception", timestamp: default, tags: tagsCollection); + + activity.AddEvent(recordExceptionEvent); + } + + private global::System.Diagnostics.Activity? GettingEntityFromStore_Activity( + int entityId, + string serviceUrl + ) + { + if (!_activitySource.HasListeners()) + { + return null; + } + + global::System.Diagnostics.Activity? activityGettingEntityFromStore = _activitySource.StartActivity("GettingEntityFromStore", global::System.Diagnostics.ActivityKind.Internal, parentId: default, tags: default, links: default, startTime: default); + + if (activityGettingEntityFromStore != null) + { + activityGettingEntityFromStore.SetTag("entity_id", entityId); + activityGettingEntityFromStore.SetBaggage("service_url", serviceUrl); + } + return activityGettingEntityFromStore; + } + + public global::System.Diagnostics.Activity? GettingEntityFromStore( + int entityId, + string serviceUrl + ) + { + var activityResult = GettingEntityFromStore_Activity(entityId, serviceUrl); + GettingEntityFromStore_Logging(entityId, serviceUrl); + GettingEntityFromStore_Metrics(entityId, serviceUrl); + + return activityResult; + } + + public void RetrievedEntity( + global::System.Diagnostics.Activity? activity, + float totalValue, + int lastUpdatedByUserId + ) + { + if (!_activitySource.HasListeners()) + { + return; + } + + if (activity != null) + { + activity.SetTag("total_value", totalValue); + activity.SetTag("last_updated_by_user_id", lastUpdatedByUserId); + } + } +} +``` + +Key points: + +- A `HasListeners()` fast-path avoids work when no listener is subscribed. +- Tag/baggage keys use snake_case (`entity_id`, `service_url`) — the OpenTelemetry naming convention. +- Multi-target methods orchestrate the per-target private methods. + +## Logging (`EntityStoreTelemetryCore.Logging.g.cs`) + +```csharp +internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry +{ + private readonly global::Microsoft.Extensions.Logging.ILogger _logger; + + private static readonly global::System.Action _gettingEntityFromStoreAction = global::Microsoft.Extensions.Logging.LoggerMessage.Define(global::Microsoft.Extensions.Logging.LogLevel.Information, new global::Microsoft.Extensions.Logging.EventId(0, "GettingEntityFromStore"), "GettingEntityFromStore: EntityId = {EntityId}, ServiceUrl = {ServiceUrl}"); + private static readonly global::System.Action _entityNotFoundAction = global::Microsoft.Extensions.Logging.LoggerMessage.Define(global::Microsoft.Extensions.Logging.LogLevel.Warning, new global::Microsoft.Extensions.Logging.EventId(0, "EntityNotFound"), "EntityNotFound: EntityId = {EntityId}"); + + public EntityStoreTelemetryCore( + global::Microsoft.Extensions.Logging.ILogger logger, + global::System.Diagnostics.Metrics.IMeterFactory meterFactory + ) + { + _logger = logger; + InitializeMeters(meterFactory); + } + + private void GettingEntityFromStore_Logging(int entityId, string serviceUrl) + { + if (!_logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information)) + { + return; + } + + _gettingEntityFromStoreAction(_logger, entityId, serviceUrl, null); + } + + public void EntityNotFound(int entityId) + { + if (!_logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Warning)) + { + return; + } + + _entityNotFoundAction(_logger, entityId, null); + } +} +``` + +These methods fit the v1 limits (≤ 6 non-exception parameters, no `[ExpandEnumerable]`/`[LogProperties]`), so `LoggerGenerationMode.Auto` selected v1 `LoggerMessage.Define` generation with an `IsEnabled` fast-path. See [Logging](Logging.md). + +## Metrics (`EntityStoreTelemetryCore.Metric.g.cs`) + +```csharp +internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry +{ + private global::System.Diagnostics.Metrics.Meter _meter = default!; + + private global::System.Diagnostics.Metrics.Counter _gettingEntityFromStoreInstrument = default!; + private global::System.Diagnostics.Metrics.Histogram _recordEntitySizeInstrument = default!; + + public void InitializeMeters(global::System.Diagnostics.Metrics.IMeterFactory meterFactory) + { + if (_meter != null) + { + throw new global::System.Exception("The meters have already been initialized."); + } + + global::System.Collections.Generic.Dictionary meterTags = new global::System.Collections.Generic.Dictionary(); + + PopulateMeterTags(meterTags); + + _meter = meterFactory.Create(new global::System.Diagnostics.Metrics.MeterOptions("SampleApp.APIService") { + Version = null, + Tags = meterTags + }); + + _gettingEntityFromStoreInstrument = _meter.CreateCounter(name: "entity_store.getting_entity_from_store", unit: null, description: null, tags: gettingEntityFromStoreTags); + _recordEntitySizeInstrument = _meter.CreateHistogram(name: "entity_store.record_entity_size", unit: null, description: null, tags: recordEntitySizeTags); + } + + partial void PopulateMeterTags(global::System.Collections.Generic.Dictionary meterTags); + partial void PopulateGettingEntityFromStoreTags(global::System.Collections.Generic.Dictionary instrumentTags); + partial void PopulateRecordEntitySizeTags(global::System.Collections.Generic.Dictionary instrumentTags); + + private void GettingEntityFromStore_Metrics(int entityId, string serviceUrl) + { + _gettingEntityFromStoreInstrument.Add(1, new global::System.Collections.Generic.KeyValuePair("entity_id", entityId), new global::System.Collections.Generic.KeyValuePair("service_url", serviceUrl)); + } + + public void RecordEntitySize(int sizeInBytes) + { + _recordEntitySizeInstrument.Record(sizeInBytes); + } +} +``` + +Key points: + +- The meter name defaults to the **assembly name** (`SampleApp.APIService`). +- `[AutoCounter]` emits `Add(1, ...)`. +- `PopulateMeterTags`/`Populate{Method}Tags` partial methods let you add tags at initialisation. + +## DI registration (`EntityStoreTelemetryCoreDIExtension.DependencyInjection.g.cs`) + +```csharp +namespace Microsoft.Extensions.DependencyInjection +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static partial class EntityStoreTelemetryCoreDIExtension + { + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddEntityStoreTelemetry( + this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services + ) + { + return services.AddSingleton(); + } + } +} +``` + +## Telemetry names (`SampleApp.APIService.TelemetryNames.g.cs`) + +```csharp +namespace SampleApp.APIService +{ + public static partial class TelemetryNames + { + public static readonly string[] MeterNames = new string[] { "SampleApp.APIService" }; + + public static readonly string[] ActivitySourceNames = new string[] { "sample-weather-app-api" }; + } +} +``` + +The assembly-level `[assembly: ActivitySourceGeneration("sample-weather-app-api")]` supplies the ActivitySource name; the meter name defaults to the assembly name. + +## See also + +- [Sample Application](Sample-Application.md) — the full source +- [Generation](Generation.md) — controlling class names, DI, and names +- [Diagnostics](Diagnostics.md) — analyzer rules \ No newline at end of file diff --git a/docs/wiki/Generation.md b/docs/wiki/Generation.md new file mode 100644 index 00000000..5169b3e5 --- /dev/null +++ b/docs/wiki/Generation.md @@ -0,0 +1,137 @@ +# Generation Options + +This page describes the `[TelemetryGeneration]` attribute and the other assembly-level generation attributes that control how code is generated: class names, DI registration, naming conventions, and the `TelemetryNames` class. + +## Generated class naming + +For an interface `IOrderServiceTelemetry`, the generator produces: + +- An implementation class named `OrderServiceTelemetryCore` (the interface name without the leading `I`, plus `Core`). +- A static DI extension class `OrderServiceTelemetryCoreDIExtension` with an `AddOrderServiceTelemetry` extension method. + +Both can be overridden with the `[TelemetryGeneration]` attribute. + +## `[TelemetryGeneration]` + +`[TelemetryGeneration]` can be applied at the **assembly** level (affects every generated interface) or on an individual **interface**. + +```csharp +// Assembly-level +[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] + +// Interface-level +[TelemetryGeneration(ClassName = "MyCustomTelemetry")] +interface IOrderServiceTelemetry { } +``` + +| Property | Default | Description | +| --- | --- | --- | +| `GenerateDependencyExtension` | `true` | Whether to emit the static DI extension class with the `Add{InterfaceName}()` method. | +| `ClassName` | `null` | Overrides the generated implementation class name. When unset, the name is `{InterfaceName without I}Core`. | +| `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. | +| `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. | + +Resolution order is interface-level first, then assembly-level, then built-in defaults. + +## Naming conventions + +The `NamingConvention` enum controls the generated telemetry names. + +```csharp +public enum NamingConvention +{ + Legacy = 0, // v3 behaviour: lowercase, smashed compounds + OpenTelemetry = 1 // v4+ default: OTel conventions +} +``` + +| Telemetry type | Legacy (v3) | OpenTelemetry (default) | +| --- | --- | --- | +| ActivitySource name | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | +| Tag / baggage keys | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | +| Metric instrument names | Lowercased, smashed: `"recordhistogram"` | Hierarchical dot.separated: `"myapp.products.record.histogram"` | +| Metric tag keys | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | + +```csharp +// Revert all telemetry to v3 naming (assembly-level) +[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] + +// Or set per-interface +[TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] +interface IMyTelemetry { } +``` + +> [!TIP] +> Use `NamingConvention.OpenTelemetry` (the default) for new projects. Only use `Legacy` if you need exact v3 name compatibility. + +## Dependency injection + +By default every telemetry interface generates a static extension class: + +```csharp +public static class OrderServiceTelemetryCoreDIExtension +{ + public static IServiceCollection AddOrderServiceTelemetry(this IServiceCollection services); +} +``` + +- The extension method name is `Add` + `{InterfaceName without the leading I}`. +- The class lives in the `Microsoft.Extensions.DependencyInjection` namespace so the extension method is discoverable with the standard `using`. +- The generated registration is `services.AddSingleton()`. +- Set `GenerateDependencyExtension = false` to disable it, or `DependencyInjectionClassIsPublic = true` to make the class public. + +## The `TelemetryNames` class + +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: + +```csharp +public static class TelemetryNames +{ + public static readonly string[] MeterNames; + public static readonly string[] ActivitySourceNames; +} +``` + +It is used to register names with OpenTelemetry/ServiceDefaults: + +```csharp +builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); +``` + +Control it with `GenerateTelemetryNamesClass`, `TelemetryNamesClassName`, and `TelemetryNamesNamespace`. + +## `[Exclude]` + +Mark a method with `[Exclude]` to skip it entirely during generation. This is useful for interface members that should not produce telemetry. + +```csharp +[Logger] +interface IOrderServiceTelemetry +{ + [Info] + void OrderPlaced(int orderId); + + [Exclude] + void DiagnosticOnly(); +} +``` + +## Assembly-level generation attributes + +| Attribute | What it controls | +| --- | --- | +| `[ActivitySourceGeneration]` | Default ActivitySource name, `DefaultToTags` behaviour, baggage/tag prefix and separator, and whether missing-Activity diagnostics (`TSG3014`/`TSG3015`) are generated. | +| `[LoggerGeneration]` | Assembly-level default log level, default `LogPrefixType`, and default logging generation mode. | +| `[MeterGeneration]` | Default meter name, meter-name generation type, instrument prefix/separator, and casing defaults. | + +See [Activities](Activities.md), [Logging](Logging.md), and [Metrics](Metrics.md) for the full property tables. + +## Next steps + +- [Activities](Activities.md), [Logging](Logging.md), [Metrics](Metrics.md) — per-target attribute reference +- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — parameter-level attributes +- [Diagnostics](Diagnostics.md) — analyzer rules \ No newline at end of file diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md new file mode 100644 index 00000000..7e86845d --- /dev/null +++ b/docs/wiki/Getting-Started.md @@ -0,0 +1,223 @@ +# Getting Started + +This guide gets you up and running with the Purview Telemetry Source Generator in minutes. You will add the package, define a telemetry interface, register it with dependency injection, and start emitting Activities, structured logs, and Metrics. + +## Installation + +Add the analyzer package to your project: + +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +Or reference it in your `.csproj` or `Directory.Build.props`: + +```xml + + all + analyzers + +``` + +You may also need runtime dependencies depending on which telemetry types you use: + +- `System.Diagnostics.DiagnosticSource` for Activities +- `Microsoft.Extensions.Logging.Abstractions` for `ILogger` +- `System.Diagnostics.Metrics` for metrics (built into .NET 8+) + +See [Installation](Installation.md) for the full dependency matrix. + +## Define a telemetry interface + +Create a `public interface` and decorate it with class-level attributes. The generator creates the implementation and a DI registration extension. + +### Single-target examples + +```csharp +using Purview.Telemetry; + +[Logger] +public interface IOrderServiceLogs +{ + [Info] + void OrderPlaced(int orderId, string customerName); + + [Warning] + void OrderNotFound(int orderId); +} +``` + +```csharp +[ActivitySource] +public interface IOrderServiceTracing +{ + [Activity] + Activity? PlacingOrder(int orderId); + + [Event] + void OrderValidated(Activity? activity, int orderId); +} +``` + +```csharp +[Meter] +public interface IOrderServiceMetrics +{ + [Counter] + void OrderPlaced(int itemsInOrder); + + [Histogram] + void OrderProcessingTime(long milliseconds); +} +``` + +### Multi-target example + +One interface can generate Activities, Logging, and Metrics from the same methods: + +```csharp +[ActivitySource] +[Logger] +[Meter] +public interface IOrderServiceTelemetry +{ + [Activity] + [Info] + [AutoCounter] + Activity? PlacingOrder(int orderId, [Baggage] string region); + + [Event] + [Trace] + void OrderProcessed(Activity? activity, long durationMs); + + [Warning] + void OrderFailed(int orderId, Exception exception); +} +``` + +## Register with DI + +The generator creates an extension method named `Add{InterfaceNameWithoutI}()`: + +```csharp +services.AddOrderServiceTelemetry(); +``` + +Inject the interface into your service: + +```csharp +public class OrderService(IOrderServiceTelemetry telemetry) +{ + public void PlaceOrder(int orderId) + { + using var activity = telemetry.PlacingOrder(orderId, "EMEA"); + // ... + telemetry.OrderProcessed(activity, stopwatch.ElapsedMilliseconds); + } +} +``` + +A single method call can emit an Activity, a log entry, and a metric simultaneously. See [Generation](Generation.md) for how registration helpers are produced and how to control them. + +## Register names with OpenTelemetry + +The generator also produces a `TelemetryNames` static class containing the meter and activity source names: + +```csharp +builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); +``` + +## Common patterns + +### Pattern 1: Scoped logging + +Return `IDisposable?` from a log method to create scoped log entries: + +```csharp +[Logger] +interface IOrderTelemetry +{ + [Info] + IDisposable? ProcessingOrder(Guid orderId); // Logs at start and end of scope + + [Error] + void OrderFailed(Exception ex, Guid orderId); +} + +public async Task ProcessOrderAsync(Guid orderId) +{ + using (telemetry.ProcessingOrder(orderId)) + { + // Processing logic here + } +} +``` + +### Pattern 2: Activity with multiple events + +Track multiple stages within a single activity: + +```csharp +[ActivitySource("ShippingService")] +interface IShippingTelemetry +{ + [Activity] + Activity? ShippingPackage([Baggage]string trackingNumber); + + [Event] + void PackageLabeled(Activity? activity); + + [Event] + void PackageWeighed(Activity? activity, [Tag]decimal weight); + + [Event] + void PackageShipped(Activity? activity, [Tag]string carrier); +} +``` + +### Pattern 3: Auto-incrementing counters + +Use `[AutoCounter]` for simple counting without a measurement parameter — every call increments by 1: + +```csharp +[Meter("ApiService")] +interface IApiMetrics +{ + [AutoCounter] + void RequestReceived([Tag]string endpoint, [Tag]string method); + + [AutoCounter] + void RequestFailed([Tag]string endpoint, [Tag]int statusCode); +} +``` + +## Viewing generated code + +To inspect the generated source, add this to your `.csproj`: + +```xml + + true + +``` + +Generated files appear under `obj/Debug|Release//generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. + +## Tips and best practices + +1. **Return `Activity?`** from Activity-starting methods, not `Activity` or `void`, so callers can dispose and reuse the activity. +2. **Pass activities explicitly** — pass the `Activity?` from the starting method to event/context methods rather than relying on `Activity.Current`. +3. **One namespace** — use the single `using Purview.Telemetry;` import. +4. **OpenTelemetry naming** — v5 defaults to OpenTelemetry conventions (snake_case tags, hierarchical metrics). See [Naming conventions](Generation.md#naming-conventions). +5. **DI registration** — use the generated `Add{InterfaceName}()` extension methods. +6. **Testing** — telemetry interfaces are easy to mock in unit tests. See [Testing](Testing.md). + +## Next steps + +- [Activities](Activities.md) — deep dive into distributed tracing with Activities, Events, and Context +- [Logging](Logging.md) — structured logging and the v1/v2 generation modes +- [Metrics](Metrics.md) — counters, histograms, and observable instruments +- [Multi-Targeting](Multi-Targeting.md) — combine multiple telemetry types in one interface +- [Generation Options](Generation.md) — control code generation, DI, and naming +- [Generated Output](Generated-Output.md) — see what code is actually generated +- [Migration](Refactorings.md) — convert existing `ILogger`/`ActivitySource`/metrics code using the IDE refactorings \ No newline at end of file diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 00000000..a816e041 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,219 @@ +# Purview Telemetry Source Generator + +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 telemetry from methods you define on an interface. Define the interface once and the generator produces the implementation, DI registration helpers, and multi-target generation — no runtime reflection, no boilerplate. + +This approach allows for: + +- **Zero boilerplate** — define methods on an interface, get the full telemetry implementation generated +- **Multi-target generation** — generate Activities, Logging, and Metrics from a single interface +- **Testable** — easy mocking/substitution for unit testing +- **DI-ready** — automatic dependency injection registration helpers +- **OpenTelemetry-aligned** — defaults to OpenTelemetry semantic conventions for better observability + +## Supported frameworks + +- .NET 8 or higher +- .NET Framework 4.8 or higher + +**Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+). + +## Documentation + +### Getting started + +- [Getting Started](Getting-Started.md) — install, first interface, and common patterns +- [Installation](Installation.md) — supported frameworks, per-feature dependencies, and build configuration +- [Sample Application](Sample-Application.md) — full .NET Aspire demo application + +### Core features + +- [Activities](Activities.md) — distributed tracing generation with `ActivitySource` +- [Logging](Logging.md) — structured logging generation with `ILogger` + - [Generation v2](Logging-Generation-v2.md) — state-based logging with `Microsoft.Extensions.Telemetry.Abstractions` + - [Generation v1](Logging-Generation-v1.md) — legacy `LoggerMessage.Define` high-performance mode +- [Metrics](Metrics.md) — metrics generation (Counters, Histograms, Observables) +- [Multi-Targeting](Multi-Targeting.md) — combine Activities + Logging + Metrics in one interface + +### Configuration and reference + +- [Generation Options](Generation.md) — control class names, DI, and code generation +- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — adding tags/baggage/properties to telemetry +- [Generated Output](Generated-Output.md) — examples of generated code +- [Diagnostics](Diagnostics.md) — analyzer warnings and errors +- [FAQ](FAQ.md) — frequently asked questions and troubleshooting +- [Breaking Changes](Breaking-Changes.md) — migration guide for v3 → v4 → v5 +- [Performance](Performance.md) — cross-runtime benchmark results + +### Migrating + +- [Refactorings](Refactorings.md) — the shipped IDE code refactorings +- [Migration from ILogger](Migration-From-ILogger.md) +- [Migration from Activities](Migration-From-Activities.md) + +### Contributing + +- [Testing](Testing.md) — mocking generated telemetry interfaces +- [Contributing](Contributing.md) — development setup, build commands, and conventions +- [Release Flow](Release-Flow.md) — how releases are produced + +## Basic examples + +Each generation target ([Activities](Activities.md), [Logging](Logging.md), and [Metrics](Metrics.md)) documents what can be inferred and what must be explicit. By default each interface used as a source for generation includes an extension method for registering it with an `IServiceCollection`; more details can be found in [Generation](Generation.md). + +> [!TIP] +> You can mix-and-match generation targets within a single interface; this is called [multi-targeting](Multi-Targeting.md). When you do, inference is disabled and every method must declare its targets explicitly. + +> [!NOTE] +> In .NET, Activities, Events, and Metrics capture additional properties at creation, recording, or observation time as **tags**. OpenTelemetry calls these **attributes**. Because this source generator makes extensive use of marker attributes to control code generation, these docs use *tags* for the properties and *attributes* for the .NET [`Attribute`](https://learn.microsoft.com/en-us/dotnet/api/system.attribute) type. + +All marker attributes are generated as `[Conditional("PURVIEW_TELEMETRY_ATTRIBUTES")]`, so they are not present in your build unless you define the `PURVIEW_TELEMETRY_ATTRIBUTES` constant. They are generated as internal to avoid exposing them outside the assembly. + +### Activities + +Basic example of an activity-based telemetry interface. There is one Activity (`GettingItemFromCache`) and four events. Calling these adds an `ActivityEvent` to the `activity` parameter; if no Activity is passed in, `Activity.Current` is used instead. There is also a context method that adds its parameters as either tags or baggage to the current Activity. + +```csharp +using Purview.Telemetry; + +[ActivitySource("some-activity")] +interface IActivityTelemetry +{ + [Activity] + Activity? GettingItemFromCache([Baggage]string key, [Tag]string itemType); + + [Event("cachemiss")] + void Miss(Activity? activity); + + [Event("cachehit")] + void Hit(Activity? activity); + + [Event] + void Error(Activity? activity, Exception ex); + + [Event] + void Finished(Activity? activity, [Tag]TimeSpan duration); + + [Context] + void AdditionalInfo(Activity? activity, string state); +} +``` + +More information can be found in [Activities](Activities.md). + +### Logging + +Basic example of a structured logging-based interface. `ProcessingWorkItem` returns an `IDisposable?`, which creates a scoped log entry. All parameters are passed into the logger methods as properties. + +```csharp +using Purview.Telemetry; + +[Logger] +interface ILoggingTelemetry +{ + [Log] + IDisposable? ProcessingWorkItem(Guid id); + + [Log(LogLevel.Trace)] + void ProcessingItemType(ItemTypes itemType); + + [Error] + void FailedToProcessWorkItem(Exception ex); + + [Info] + void ProcessingComplete(bool success, TimeSpan duration); +} +``` + +More information can be found in [Logging](Logging.md), including the two generation modes and how to disable logging generation when the `Microsoft.Extensions.Logging` types are unavailable. + +### Metrics + +This example shows each meter type currently supported. The `Counter` attribute is demonstrated twice: once with `AutoIncrement = true` (the measurement value is set to 1 per call) and once with the measurement specified explicitly as a parameter. + +> [!IMPORTANT] +> Non-auto-increment instruments must specify a measurement of one of the supported types: `byte`, `short`, `int`, `long`, `float`, `double`, or `decimal`. + +> [!NOTE] +> Observable instruments must always have a `System.Func<>` parameter with one of the following shapes: +> +> - Any supported measurement type (`byte`, `short`, `int`, `long`, `float`, `double`, or `decimal`) +> - `Measurement` where `T` is a supported measurement type +> - `IEnumerable>` where `T` is a supported measurement type + +As with activities, `[Tag]` parameters are included at recording time for the instrument. + +```csharp +using Purview.Telemetry; + +[Meter] +interface IMeterTelemetry +{ + [AutoCounter] + void AutoIncrementMeter([Tag]string someValue); + + [Counter(AutoIncrement = true)] + void AutoIncrementCounterMeter([Tag]string someValue); + + [Counter] + void CounterMeter([InstrumentMeasurement]int measurement, [Tag]float someValue); + + [Histogram] + void HistogramMeter([InstrumentMeasurement]int measurement, [Tag]int someValue, [Tag]bool anotherValue); + + [ObservableCounter] + void ObservableCounterMeter(Func measurement, [Tag]double someValue); + + [ObservableGauge] + void ObservableGaugeMeter(Func> measurement, [Tag]double someValue); + + [ObservableUpDownCounter] + void ObservableUpDownCounter(Func>> measurement, [Tag]double someValue); + + [UpDownCounter] + void UpDownCounterMeter([InstrumentMeasurement]decimal measurement, [Tag]byte someValue); +} +``` + +More information can be found in [Metrics](Metrics.md). + +## Multi-targeting + +In this example all method-level targets are set explicitly — inferring usage is not supported when multi-targeting. + +```csharp +using Purview.Telemetry; + +[ActivitySource("multi-targeting")] +[Logger] +[Meter] +interface IServiceTelemetry +{ + [Activity] + [Trace] + Activity? StartAnActivity(string tagStringParam, [Baggage]int entityId); + + [Event] + [Info] + void AnInterestingEvent(Activity? activity, float aTagValue); + + [Error] + [Event] + [AutoCounter] + void AnError(Activity? activity, Exception ex); + + [Context] + [AutoCounter] + [Debug] + void InterestingInfo(Activity? activity, float anotherTagValue, int intTagValue); + + [Histogram] + [Trace] + void ProcessingEntity(int entityId, string property1); + + [Info] + [Counter] + void ACounter([Tag]int value); +} +``` + +More information can be found in [Multi-Targeting](Multi-Targeting.md). \ No newline at end of file diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md new file mode 100644 index 00000000..53224e1c --- /dev/null +++ b/docs/wiki/Installation.md @@ -0,0 +1,165 @@ +# Installation + +This page covers supported frameworks, installation methods, per-feature dependencies, and build configuration for the `Purview.Telemetry.SourceGenerator` package. + +## Supported frameworks + +**Consumer runtime targets:** + +- .NET Framework 4.8 +- .NET 8 or higher + +**Build toolchain requirement:** + +- Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+) + +The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. + +## Install the package + +### .NET CLI + +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +### Package Manager Console + +```powershell +Install-Package Purview.Telemetry.SourceGenerator +``` + +### Project file (.csproj or Directory.Build.props) + +```xml + + all + analyzers + +``` + +`PrivateAssets="all"` keeps the generator out of consumers' dependencies and `IncludeAssets="analyzers"` treats it purely as an analyzer. + +## Runtime dependencies + +| Telemetry type | Required reference | Notes | +| --- | --- | --- | +| Activities | `System.Diagnostics.DiagnosticSource` | Included in the SDK for .NET 5+ | +| Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved | +| Logging (v2) | `Microsoft.Extensions.Telemetry.Abstractions` | Required for state-based v2 generation (`LoggerMessageHelper`, `[LogProperties]`) | +| Metrics | `System.Diagnostics.Metrics` | Included in the SDK for .NET 8+ | + +## Verifying the install + +Create an interface with a telemetry attribute and build: + +```csharp +using Purview.Telemetry; + +[Logger] +interface IMyTelemetry +{ + [Info] + void Hello(); +} +``` + +If generation succeeds you will see a `MyTelemetryCore` implementation and an `AddMyTelemetry` extension method. To inspect the generated code, enable: + +```xml + + true + +``` + +Generated files land in `obj/Debug|Release//generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. + +## Build configuration + +### `PURVIEW_TELEMETRY_ATTRIBUTES` + +Marker attributes are generated as `[Conditional("PURVIEW_TELEMETRY_ATTRIBUTES")]` and are internal to your assembly. Define this constant if you want to retain them in your build (for example, so the analyzer sees attribute usage in projects that share attribute source): + +```xml + + $(DefineConstants);PURVIEW_TELEMETRY_ATTRIBUTES + +``` + +### `EXCLUDE_PURVIEW_TELEMETRY_LOGGING` + +Define this constant to disable logging generation entirely. This is useful when the `Microsoft.Extensions.Logging` types are not available in the compilation: + +```xml + + $(DefineConstants);EXCLUDE_PURVIEW_TELEMETRY_LOGGING + +``` + +### `PURVIEW_TELEMETRY_NON_NULLABLE` + +Define this constant to opt out of the `TSG1011` unsupported-target-framework check for non-net8+/net48+ compilations: + +```xml + + $(DefineConstants);PURVIEW_TELEMETRY_NON_NULLABLE + +``` + +## Project-type snippets + +### ASP.NET Core + +```csharp +builder.Services.AddWeatherServiceTelemetry(); +``` + +### Console / library + +```csharp +services.AddOrderServiceTelemetry(); +``` + +### .NET Aspire + +```csharp +builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); +``` + +See the [sample application](Sample-Application.md) for a complete Aspire setup on .NET 10. + +## Troubleshooting + +**"Type or namespace 'ActivitySourceAttribute' could not be found"** + +- Ensure `using Purview.Telemetry;` is present (all attributes live in the single `Purview.Telemetry` namespace). +- Check the package reference includes `IncludeAssets="analyzers"`. + +**"No implementation found for interface"** + +- Verify the interface has at least one of `[ActivitySource]`, `[Logger]`, or `[Meter]`. +- Check that methods have the appropriate method-level attributes. +- Rebuild to trigger source generation. + +**"ActivitySource not producing traces"** + +- Ensure the generated `Add{InterfaceName}()` DI registration method has been called. +- Configure OpenTelemetry to listen to your ActivitySource name. + +**"Logs not appearing"** + +- Verify `ILogger` is configured in your application. +- Check that log-level filters are not excluding your messages. +- Ensure DI registration was called. + +**"Metrics not collected"** + +- Configure a metrics exporter in your application. +- Verify the meter name matches what your collector expects. +- Confirm instruments are recorded with valid measurement values. + +## Next steps + +- [Getting Started](Getting-Started.md) — write your first telemetry interface +- [Generation Options](Generation.md) — control class names, DI, and naming +- [Diagnostics](Diagnostics.md) — analyzer warnings and errors \ No newline at end of file diff --git a/docs/wiki/Logging-Generation-v1.md b/docs/wiki/Logging-Generation-v1.md new file mode 100644 index 00000000..34fb7635 --- /dev/null +++ b/docs/wiki/Logging-Generation-v1.md @@ -0,0 +1,86 @@ +# Logging Generation v1 + +> [!IMPORTANT] +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. + +Generation v1 is the previous style of log generation, built on [`LoggerMessage.Define`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessagedefine) from the [high-performance logging](https://learn.microsoft.com/en-us/dotnet/core/extensions/high-performance-logging) libraries. + +It has these limitations: + +- A maximum of **6** non-exception parameters. +- At most **one** `Exception` parameter. +- No support for expanding enumerations (`[ExpandEnumerable]` has no effect). +- No support for `[LogProperties]`. + +> [!WARNING] +> The maximum number of parameters allowed by the `LoggerMessage` class is **6**, plus one optional `Exception`. You must reference the `Microsoft.Extensions.Logging` package. + +## Selecting v1 + +v1 is used automatically in `LoggerGenerationMode.Auto` for any method that is within the v1 limits. You can force it for all methods on an interface or assembly: + +```csharp +[Logger(GenerationMode = LoggerGenerationMode.V1)] +interface IOrderServiceTelemetry { } +``` + +## Log generation + +To generate a log entry, decorate the method with `[Log]` and return either `void` (non-scoped) or `IDisposable`/`IDisposable?` (scoped). Parameters form part of the structured log generation and are referenced by the `MessageTemplate`. + +You can also use the semantic level attributes instead of `[Log].Level`: + +- `[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]`, `[Critical]` + +These support all the same properties as `[Log]`, except `Level`. + +## Inferring + +When not using multi-targeting you can omit `[Log]` entirely — every method on a `[Logger]` interface becomes a log method. Set the default level with `[Logger].DefaultLevel` (interface) or `[LoggerGeneration].DefaultLevel` (assembly). + +If no level is specified on the method and an `Exception` parameter is present, the level is changed to `Error` automatically. This raises the `TSG2002` diagnostic, which can safely be ignored if you are comfortable with the inferred behaviour. + +## Attribute reference + +### `[Log]` + +| Property | Type | Description | +| --- | --- | --- | +| `Level` | [`LogLevel`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel) | The level used when defining the log entry. Available on construction. Defaults to `Information`, unless an `Exception` is detected in the parameters, then `Error`. | +| `MessageTemplate` | `string?` | The template used to populate the log entry. If not specified, one is generated from the prefixes and available parameters. Available on construction. Default `null`. | +| `EventId` | `int?` | Used when generating the [`EventId`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.eventid). Available on construction. Default `null` (one is generated if not supplied). | +| `Name` | `string?` | The name of the log entry. If not defined, the method name is used. Available on construction. Default `null`. | +| `GenerationMode` | `LoggerGenerationMode` | Per-method generation-mode override. `Auto` (default) inherits from the interface/assembly level. | + +### `[Logger]` + +| Property | Type | Description | +| --- | --- | --- | +| `DefaultLevel` | `LogLevel` | The default level when one is not provided. Available on construction. Default `Information`. | +| `CustomPrefix` | `string?` | Used when generating the log entry name's prefix. When set, `PrefixType` is automatically `Custom`. Available on construction. Default `null`. | +| `PrefixType` | `LogPrefixType` | The prefix type used when generating the log entry name. Default `Default`. | +| `GenerationMode` | `LoggerGenerationMode` | Controls the generation mode for all log methods on the interface. `Auto` (default) selects the best mode per method. | + +### `[LoggerGeneration]` + +| Property | Type | Description | +| --- | --- | --- | +| `DefaultLevel` | `LogLevel` | The default level when one is not provided. Available on construction. Default `Information`. | +| `GenerationMode` | `LoggerGenerationMode` | Controls the generation mode for all log methods in the assembly. `Auto` (default) selects the best mode per method. | +| `DefaultPrefixType` | `LogPrefixType` | The default prefix type for all log entries in the assembly. Default `Default`. | + +### `LogPrefixType` + +| Value | Description | +| --- | --- | +| `Default` | No prefix. | +| `Interface` | Uses the name of the interface. | +| `Class` | The name of the class used for generation (`TelemetryGenerationAttribute.ClassName` or auto-generated). | +| `Custom` | Used when `LoggerAttribute.CustomPrefix` is set. | +| `TrimmedClassName` | The interface name without the `I` prefix or `Log`, `Logger`, or `Telemetry` suffixes. | + +## Next steps + +- [Logging Generation v2](Logging-Generation-v2.md) — the state-based mode with `[ExpandEnumerable]`/`[LogProperties]` +- [Logging](Logging.md) — choosing between v1 and v2 +- [Diagnostics](Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file diff --git a/docs/wiki/Logging-Generation-v2.md b/docs/wiki/Logging-Generation-v2.md new file mode 100644 index 00000000..b192cfc1 --- /dev/null +++ b/docs/wiki/Logging-Generation-v2.md @@ -0,0 +1,89 @@ +# Logging Generation v2 + +> [!IMPORTANT] +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. + +Generation v2 is the state-based logging mode. Its output closely resembles the built-in [`[LoggerMessage]`](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) generator, but with additional features: dynamically generated `MessageTemplate`s and the ability to expand array/`IEnumerable` parameters. + +It is used automatically in `LoggerGenerationMode.Auto` for methods that exceed the [v1](Logging-Generation-v1.md) limits (more than 6 non-exception parameters, or `[ExpandEnumerable]`/`[LogProperties]` parameters). + +> [!IMPORTANT] +> Generation v2 requires the `Microsoft.Extensions.Telemetry.Abstractions` package (`LoggerMessageHelper` and `LogPropertiesAttribute`). Reference it directly or transitively when any method uses v2 generation. + +## Selecting v2 + +```csharp +// Force v2 for the whole interface +[Logger(GenerationMode = LoggerGenerationMode.V2)] +interface IOrderServiceTelemetry { } + +// Or per method +[Logger] +interface IOrderServiceTelemetry +{ + [Info(GenerationMode = LoggerGenerationMode.V2)] + void OrderPlaced(int orderId, string customerName); +} +``` + +## Log generation + +As with v1, decorate a method with `[Log]` (or a semantic level attribute) and return either `void` (non-scoped) or `IDisposable`/`IDisposable?` (scoped). + +The shared `[Log]`, `[Logger]`, `[LoggerGeneration]`, and `LogPrefixType` reference tables are on the [Logging Generation v1](Logging-Generation-v1.md#log) page. + +## Custom message templates + +`[Log].MessageTemplate` lets you customise the log message. Template placeholders map to method parameters: + +```csharp +[Logger] +interface IOrderServiceTelemetry +{ + [Info("Order {OrderId} placed for {CustomerName}")] + void OrderPlaced(int orderId, string customerName); +} +``` + +If no template is specified, one is generated from the method name and parameters. + +## `[ExpandEnumerable]` + +Applied to an array or `IEnumerable` parameter, it logs the individual elements rather than the collection as a whole. + +| Property | Type | Description | +| --- | --- | --- | +| `MaximumValueCount` | `int` | The maximum number of elements to output. Default `5`. | + +```csharp +[Logger] +interface IOrderServiceTelemetry +{ + [Info] + void OrdersRetrieved(int orderId, [ExpandEnumerable(maximumValueCount: 100)] string[] orderNumbers); +} +``` + +> [!NOTE] +> A `MaximumValueCount` greater than the recommended default of 5 generates the `TSG2008` warning. It can be ignored, but test your application's performance thoroughly. + +## `[LogProperties]` + +The external `Microsoft.Extensions.Logging.LogPropertiesAttribute` (from `Microsoft.Extensions.Telemetry.Abstractions`) expands an object's public properties into individual log properties. + +| Property | Type | Description | +| --- | --- | --- | +| `OmitReferenceName` | `bool` | Whether the reference name is omitted from property names. Default `false`. | +| `SkipNullProperties` | `bool` | Whether null properties are skipped. Default `false`. | +| `Transitive` | `bool` | Whether nested objects are expanded transitively. Default `false`. | + +The companion `LogPropertyIgnoreAttribute` marks a property to be skipped during expansion. + +> [!IMPORTANT] +> `[LogProperties]` and `[ExpandEnumerable]` cannot be applied to the same parameter — this raises `TSG2006`. + +## Next steps + +- [Logging Generation v1](Logging-Generation-v1.md) — the `LoggerMessage.Define` mode and its limits +- [Logging](Logging.md) — choosing between v1 and v2 +- [Diagnostics](Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file diff --git a/docs/wiki/Logging.md b/docs/wiki/Logging.md new file mode 100644 index 00000000..8def0f37 --- /dev/null +++ b/docs/wiki/Logging.md @@ -0,0 +1,76 @@ +# Logging + +There are two distinct generated types for [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger)-based generation. + +To signal an interface for logging generation, decorate it with the `[Logger]` attribute. To signal a method, use `[Log]` (or one of the semantic level attributes). All logging attributes live in the `Purview.Telemetry` namespace. + +## Generation v1 vs v2 + +| | Generation v1 | Generation v2 | +| --- | --- | --- | +| Implementation | [`LoggerMessage`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessage)/[`LoggerMessage.Define`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessagedefine) high-performance logging | State-based output resembling the built-in [`[LoggerMessage]`](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) generator | +| Parameter limit | Maximum 6 non-exception parameters plus one optional `Exception` | No fixed parameter limit | +| `[ExpandEnumerable]` | No effect | Supported | +| `[LogProperties]` | No effect | Supported | +| Required reference | `Microsoft.Extensions.Logging` | `Microsoft.Extensions.Telemetry.Abstractions` | + +### How `Auto` mode works + +The default `LoggerGenerationMode.Auto` selects the mode **per method**: + +- **v1** is used when the method is within the v1 limits (≤ 6 non-exception parameters, at most one `Exception`, and no `[ExpandEnumerable]` or `[LogProperties]` parameters). +- **v2** is used when the method needs it (more than 6 parameters, `[ExpandEnumerable]`, or `[LogProperties]`), which requires `Microsoft.Extensions.Telemetry.Abstractions`. + +You can force a mode with the `GenerationMode` property: + +- `[Log].GenerationMode` — per method +- `[Logger].GenerationMode` — per interface +- `[LoggerGeneration].GenerationMode` — per assembly + +```csharp +// Force v2 for one method +[Logger] +interface IOrderServiceTelemetry +{ + [Info(GenerationMode = LoggerGenerationMode.V2)] + void OrderPlaced(int orderId, string customerName); +} +``` + +> [!IMPORTANT] +> If you force `V2` (or use a method that requires v2) without referencing `Microsoft.Extensions.Telemetry.Abstractions`, the generated code will not compile because it uses `LoggerMessageHelper` and `LogPropertiesAttribute` from that package. + +## Disabling logging generation + +Define the `EXCLUDE_PURVIEW_TELEMETRY_LOGGING` constant to ignore the logging attributes entirely: + +```xml + + + EXCLUDE_PURVIEW_TELEMETRY_LOGGING + +``` + +This is primarily used when the [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger) type is unavailable — without it your project will fail to compile because the generated attributes reference related types such as [`LogLevel`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel). + +## Scoped loggers + +Return `IDisposable`/`IDisposable?` from a log method to generate a scoped log entry: + +```csharp +[Logger] +interface IOrderServiceTelemetry +{ + [Info] + IDisposable? ProcessingOrder(Guid orderId); +} +``` + +When a method returns `IDisposable?`, the entry is emitted when the scope is created and the duration is logged when it is disposed. A scoped method should not specify an explicit level (`TSG2007` warns if it does, since the level is ignored). + +## Next pages + +- [Generation v1](Logging-Generation-v1.md) — `LoggerMessage.Define` mode and its limits +- [Generation v2](Logging-Generation-v2.md) — state-based mode, `[ExpandEnumerable]`, `[LogProperties]` +- [Breaking Changes](Breaking-Changes.md#namespace-consolidation) — namespace migration +- [Diagnostics](Diagnostics.md) — the `TSG2xxx` Logging rules \ No newline at end of file diff --git a/docs/wiki/Metrics.md b/docs/wiki/Metrics.md new file mode 100644 index 00000000..d749480e --- /dev/null +++ b/docs/wiki/Metrics.md @@ -0,0 +1,175 @@ +# Metrics + +> [!IMPORTANT] +> All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](Breaking-Changes.md#namespace-consolidation) for migration details. + +All metric-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for meter generation, decorate it with the `[Meter]` attribute. + +When creating the meter types, the [`IMeterFactory`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.imeterfactory) is used when available (it is not available on .NET Framework 4.8, where a `new Meter(...)` is created instead). + +## Meter naming + +The meter name is resolved in this order: + +1. `MeterAttribute.Name` (interface) +2. `MeterGenerationAttribute.MeterName` (assembly) +3. The assembly name + +```csharp +[Meter("InventoryService")] +interface IInventoryMetrics { } +``` + +When the `NamingConvention.OpenTelemetry` naming convention is combined with `MeterNameGenerationType.OpenTelemetry`, instrument names are generated with the meter name as a dot-separated prefix (for example, `myapp.products.record.count`). + +## Initialisation + +During initialisation you can implement a partial method to provide additional tags to any meters created by the `IMeterFactory`: + +```csharp +partial void PopulateMeterTags(System.Collections.Generic.Dictionary meterTags) +{ + meterTags["environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); +} +``` + +Any tags added to `meterTags` are included on every meter created. + +## Instrument types + +Each instrument is determined by its corresponding attribute: + +- `[AutoCounter]` and `[Counter]` generate the [`Counter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.counter-1) instrument. +- `[Histogram]` generates the [`Histogram`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.histogram-1) instrument. +- `[UpDownCounter]` generates the [`UpDownCounter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.updowncounter-1) instrument. +- `[ObservableCounter]` generates the [`ObservableCounter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.observablecounter-1) instrument. +- `[ObservableGauge]` generates the [`ObservableGauge`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.observablegauge-1) instrument. +- `[ObservableUpDownCounter]` generates the [`ObservableUpDownCounter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.observableupdowncounter-1) instrument. + +The measurement type must be one of `byte`, `short`, `int`, `long`, `float`, `double`, or `decimal`. + +## The measurement value + +The measurement parameter is the parameter decorated with `[InstrumentMeasurement]`. For non-auto-increment instruments, if no parameter is decorated the first parameter with a valid measurement type that is not a `[Tag]`/`[Baggage]` is used. + +- `[AutoCounter]` and `[Counter(AutoIncrement = true)]` increment by **1** each time the method is called and must **not** declare a measurement parameter (`TSG4002`). +- `[Counter]`, `[Histogram]`, and `[UpDownCounter]` require a measurement value (`TSG4004`). +- Observable instruments must declare a `Func` parameter (`TSG4005`). + +```csharp +[Meter] +interface IMeterTelemetry +{ + [AutoCounter] + void AutoIncrementMeter([Tag]string someValue); + + [Counter(AutoIncrement = true)] + void AutoIncrementCounterMeter([Tag]string someValue); + + [Counter] + void CounterMeter([InstrumentMeasurement]int measurement, [Tag]float someValue); + + [Histogram] + void HistogramMeter([InstrumentMeasurement]int measurement, [Tag]int someValue, [Tag]bool anotherValue); + + [UpDownCounter] + void UpDownCounterMeter([InstrumentMeasurement]decimal measurement, [Tag]byte someValue); +} +``` + +### Observable instruments + +Observable instruments always take a `System.Func<>` parameter with one of the following shapes: + +- Any supported measurement type: `byte`, `short`, `int`, `long`, `float`, `double`, or `decimal` +- [`Measurement`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.measurement-1) where `T` is a supported measurement type +- `IEnumerable>` where `T` is a supported measurement type + +```csharp +[ObservableCounter] +void Counter(Func func); + +[ObservableGauge] +void Gauge(Func> func); + +[ObservableUpDownCounter] +void UpDownCounter(Func>> func); +``` + +### Tags + +Other parameters on the method are used as tags. This is implicit for non-measurement parameters, but can also be made explicit with [`[Tag]`](Tags-and-Baggage.md). When there are four or more tags, the generated code uses a stack-allocated [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist). + +## Attribute reference + +### `[Meter]` + +Defines and controls the generation of meters and instruments on an interface, overriding assembly-level defaults. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string?` | The name of the meter, used to group instruments. If not specified, `MeterGenerationAttribute.MeterName` is used, then the assembly name. Default `null`. | +| `InstrumentPrefix` | `string?` | The prefix used when generating instrument names. Default `null`. | +| `IncludeAssemblyInstrumentPrefix` | `bool` | Whether the `MeterGenerationAttribute.InstrumentPrefix` prefix is included in instrument names. Default `true`. | +| `LowercaseInstrumentName` | `bool` | Whether instrument names (including prefix) are lower-cased. Default `true`. | +| `LowercaseTagKeys` | `bool` | Whether tag names are lower-cased. Default `true`. | + +### `[MeterGeneration]` + +Controls meter and instrument defaults at the assembly level. + +| Property | Type | Description | +| --- | --- | --- | +| `MeterName` | `string?` | The default meter name when none is defined on an interface. Available on construction. | +| `MeterNameGenerationType` | `MeterNameGenerationType` | Whether the meter name is used as a dot-separated prefix for instrument names. `OpenTelemetry` (0) adds the prefix; `DotNet` (1, default) does not. Available on construction. | +| `InstrumentPrefix` | `string?` | The prefix used when generating instrument names. Default `null`. | +| `InstrumentSeparator` | `string` | The separator used when generating prefixes. Default `.`. | +| `LowercaseInstrumentName` | `bool` | Whether instrument names (including prefix) are lower-cased. Default `true`. | +| `LowercaseTagKeys` | `bool` | Whether tag names are lower-cased. Default `true`. | + +### `[InstrumentMeasurement]` + +Marks the parameter used as the instrument measurement value. Not supported with `[AutoCounter]` or `[Counter(AutoIncrement = true)]`. + +## Instrument attributes + +### `[AutoCounter]` + +Creates a `Counter` that increments by 1 per call. + +| Property | Type | Description | +| --- | --- | --- | +| `Name` | `string?` | Instrument name; defaults to the method name. Available on construction. Default `null`. | +| `Unit` | `string?` | The unit used during meter generation. Available on construction. Default `null`. | +| `Description` | `string?` | The description used during meter generation. Available on construction. Default `null`. | + +### `[Counter]` + +Creates a `Counter`. + +| Property | Type | Description | +| --- | --- | --- | +| `AutoIncrement` | `bool` | When `true`, generates an auto-incrementing counter instead of accepting a measurement value from a parameter. Available on construction. Default `false`. | +| `Name` | `string?` | Instrument name; defaults to the method name. Available on construction. Default `null`. | +| `Unit` | `string?` | The unit used during meter generation. Available on construction. Default `null`. | +| `Description` | `string?` | The description used during meter generation. Available on construction. Default `null`. | + +When `AutoIncrement` is `true` and `[InstrumentMeasurement]` is used, the `TSG4002` diagnostic is raised. + +### `[Histogram]` and `[UpDownCounter]` + +Create a `Histogram` or `UpDownCounter`. Both share the `Name`, `Unit`, and `Description` properties described above. + +### Observable attributes + +`[ObservableCounter]`, `[ObservableGauge]`, and `[ObservableUpDownCounter]` share the `Name`, `Unit`, and `Description` properties, plus: + +| Property | Type | Description | +| --- | --- | --- | +| `ThrowOnAlreadyInitialized` | `bool` | Whether the method throws when called more than once. Available on construction. Default `false`. | + +## Next steps + +- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — tagging parameters +- [Multi-Targeting](Multi-Targeting.md) — combining Metrics with Activities and Logging +- [Diagnostics](Diagnostics.md) — the `TSG4xxx` Metrics rules \ No newline at end of file diff --git a/docs/wiki/Migration-From-Activities.md b/docs/wiki/Migration-From-Activities.md new file mode 100644 index 00000000..12519e73 --- /dev/null +++ b/docs/wiki/Migration-From-Activities.md @@ -0,0 +1,89 @@ +# Migration from Activities + +This guide covers converting hand-written `ActivitySource`/`Activity` code to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](Refactorings.md) — this page documents the manual fallback. + +## Before + +```csharp +using System.Diagnostics; + +public class OrderService +{ + static readonly ActivitySource _source = new("OrderService"); + + public void PlaceOrder(int orderId) + { + using var activity = _source.StartActivity("PlaceOrder", ActivityKind.Internal); + activity?.AddEvent(new ActivityEvent("Validated")); + } +} +``` + +## After + +```csharp +using System.Diagnostics; +using Purview.Telemetry; + +[ActivitySource] +public interface IOrderServiceTracing +{ + [Activity] + Activity? PlaceOrder(int orderId); + + [Event] + void Validated(Activity? activity); +} + +public class OrderService(IOrderServiceTracing tracing) +{ + public void PlaceOrder(int orderId) + { + using var activity = tracing.PlaceOrder(orderId); + tracing.Validated(activity); + } +} +``` + +Register the interface in DI: + +```csharp +services.AddOrderServiceTracing(); +``` + +## Common conversions + +| Original code | Generated method signature | +| --- | --- | +| `_source.StartActivity("PlaceOrder")` | `[Activity] Activity? PlaceOrder();` | +| `_source.StartActivity("Order", ActivityKind.Internal)` | `[Activity(ActivityKind.Internal)] Activity? Order();` | +| `activity.AddEvent(new ActivityEvent("Loaded"))` | `[Event] void Loaded(Activity? activity);` | +| `activity.SetBaggage("tenant", tenantId)` | `[Context] void SetTenant(Activity? activity, [Baggage] string tenantId);` | +| `activity.SetTag("tenant", tenantId)` | `[Context] void SetTenant(Activity? activity, [Tag] string tenantId);` | + +## Return types + +- Activity-starting methods should return `Activity?` so callers can dispose and reuse the Activity. +- Event and Context methods should accept the `Activity?` as their first parameter and typically return `void`. + +## Tags and baggage + +Use `[Tag]` and `[Baggage]` on parameters to control how values are attached — see [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md). + +## Naming + +The ActivitySource name defaults to the assembly name with casing preserved. Set `[ActivitySource("MyApp")]` to override. v4+ converts tag/baggage keys to snake_case by default; revert with `[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]`. + +## Checklist + +1. Add the `Purview.Telemetry.SourceGenerator` package. +2. Create the `[ActivitySource]` interface (or run the [ActivitySource refactoring](Refactorings.md#convert-activitysource-to-iclassnametracing)). +3. Replace `ActivitySource` fields and `StartActivity`/`AddEvent`/`SetTag`/`SetBaggage` calls with the interface. +4. Register with `services.Add{InterfaceNameWithoutI}()`. +5. Rebuild and review the [Diagnostics](Diagnostics.md). + +## Next steps + +- [Refactorings](Refactorings.md) — the shipped IDE conversions +- [Activities](Activities.md) — full attribute reference +- [Multi-Targeting](Multi-Targeting.md) — combine with Logging and Metrics \ No newline at end of file diff --git a/docs/wiki/Migration-From-ILogger.md b/docs/wiki/Migration-From-ILogger.md new file mode 100644 index 00000000..bc7ad6b5 --- /dev/null +++ b/docs/wiki/Migration-From-ILogger.md @@ -0,0 +1,124 @@ +# Migration from ILogger + +This guide covers converting hand-written `ILogger` usage to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](Refactorings.md) — this page documents the manual fallback. + +## Before + +```csharp +using Microsoft.Extensions.Logging; + +public class OrderService(ILogger logger) +{ + public void PlaceOrder(int orderId, string customerName) + { + logger.LogInformation("Placing order {OrderId} for {CustomerName}", orderId, customerName); + } + + public void CancelOrder(int orderId, Exception ex) + { + logger.LogError(ex, "Order {OrderId} cancelled", orderId); + } +} +``` + +## After + +```csharp +using Purview.Telemetry; + +[Logger] +public interface IOrderServiceLogs +{ + [Info("Placing order {OrderId} for {CustomerName}")] + void OrderPlaced(int orderId, string customerName); + + [Error("Order {OrderId} cancelled")] + void OrderCancelled(Exception ex, int orderId); +} + +public class OrderService(IOrderServiceLogs logger) +{ + public void PlaceOrder(int orderId, string customerName) + { + logger.OrderPlaced(orderId, customerName); + } + + public void CancelOrder(int orderId, Exception ex) + { + logger.OrderCancelled(ex, orderId); + } +} +``` + +Register the interface in DI: + +```csharp +services.AddOrderServiceLogs(); +``` + +## Log-level mapping + +| `ILogger` call | Generated attribute | +| --- | --- | +| `LogTrace(...)` | `[Trace]` | +| `LogDebug(...)` | `[Debug]` | +| `LogInformation(...)` | `[Info]` | +| `LogWarning(...)` | `[Warning]` | +| `LogError(...)` | `[Error]` | +| `LogCritical(...)` | `[Critical]` | +| `Log(LogLevel.X, ...)` | `[Log(LogLevel.X, ...)]` | + +## Scoped logging + +Return `IDisposable?` from the method to generate a scoped log entry: + +```csharp +[Logger] +public interface IOrderServiceLogs +{ + [Info("Processing order {OrderId}")] + IDisposable? ProcessingOrder(int orderId); +} +``` + +## `LoggerMessage.Define` + +Replace a `LoggerMessage.Define` static field with a generated method: + +```csharp +// Before +static readonly Action, int, Exception?> _failed = + LoggerMessage.Define(LogLevel.Error, 0, "Order {OrderId} failed"); + +_failed(_logger, orderId, ex); +``` + +```csharp +// After +[Error("Order {OrderId} failed")] +void OrderFailed(int orderId, Exception ex); + +logger.OrderFailed(orderId, ex); +``` + +## Structured data + +For collections, use `[ExpandEnumerable]`; for objects, use `[LogProperties]` — see [Logging Generation v2](Logging-Generation-v2.md). + +## Naming + +v4+ converts parameter names to snake_case by default (OpenTelemetry convention). To revert to v3 naming, apply `[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]`. See [Naming conventions](Generation.md#naming-conventions). + +## Checklist + +1. Add the `Purview.Telemetry.SourceGenerator` package and `Microsoft.Extensions.Logging.Abstractions`. +2. Create the `[Logger]` interface (or run the [ILogger refactoring](Refactorings.md#convert-ilogger-to-iclassnamelogs)). +3. Replace `ILogger` members and calls with the interface. +4. Register with `services.Add{InterfaceNameWithoutI}()`. +5. Rebuild and review the [Diagnostics](Diagnostics.md). + +## Next steps + +- [Refactorings](Refactorings.md) — the shipped IDE conversions +- [Logging](Logging.md) — v1/v2 generation modes +- [Multi-Targeting](Multi-Targeting.md) — combine with Activities and Metrics \ No newline at end of file diff --git a/docs/wiki/Multi-Targeting.md b/docs/wiki/Multi-Targeting.md new file mode 100644 index 00000000..bcbe9fbb --- /dev/null +++ b/docs/wiki/Multi-Targeting.md @@ -0,0 +1,274 @@ +# Multi-Targeting + +Multi-targeting lets you generate **multiple types of telemetry** (Activities, Logs, and Metrics) from a single interface or even a single method. One method call can emit an Activity, a structured log entry, and a metric simultaneously. + +## Interface-level multi-targeting + +Apply multiple generation attributes to an interface to enable all telemetry types: + +```csharp +using Purview.Telemetry; + +[ActivitySource("OrderService")] +[Logger] +[Meter("OrderService")] +interface IOrderTelemetry +{ + // Methods can use one or more telemetry types +} +``` + +## Method-level multi-targeting + +Multiple telemetry attributes can be combined on a single method. When called, the method emits all specified telemetry types: + +```csharp +[ActivitySource("OrderService")] +[Logger] +[Meter] +interface IOrderTelemetry +{ + // MULTI-TARGET: Creates Activity + Logs Info + Increments Counter from one call + [Activity] + [Info] + [AutoCounter] + Activity? ProcessingOrder([Baggage]int orderId, [Tag]string customerName); + + // MULTI-TARGET: Adds ActivityEvent + Logs as Debug + [Event] + [Debug] + void OrderValidated(Activity? activity, decimal amount); + + // SINGLE-TARGET: only logs + [Warning] + void OrderRejected(int orderId, string reason); + + // SINGLE-TARGET: only metric + [Histogram] + void OrderProcessingDuration([InstrumentMeasurement]int milliseconds); +} +``` + +```csharp +// Single method call emits 3 telemetry types +using var activity = telemetry.ProcessingOrder(123, "Acme Corp"); +// ✓ Activity created and started +// ✓ Info log entry written +// ✓ Counter incremented by 1 +``` + +### Supported combinations + +| Combination | Supported | Example | +| --- | --- | --- | +| Activity + Log | ✅ | `[Activity]` + `[Info]` | +| Activity + Metric | ✅ | `[Activity]` + `[AutoCounter]` | +| Log + Metric | ✅ | `[Info]` + `[Histogram]` | +| Activity + Log + Metric | ✅ | `[Activity]` + `[Info]` + `[AutoCounter]` | +| Event + Log | ✅ | `[Event]` + `[Debug]` | +| Context + Log | ✅ | `[Context]` + `[Trace]` | + +**Rules:** + +- Only **one attribute per telemetry family** is allowed per method (`TSG1002`). +- Activities: use one of `[Activity]`, `[Event]`, or `[Context]`. +- Logging: use one of `[Log]`, `[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]`, `[Critical]`. +- Metrics: use one of `[Counter]`, `[AutoCounter]`, `[Histogram]`, `[UpDownCounter]`, or the observable variants. + +```csharp +// ERROR TSG1002: multiple activity attributes +[Activity] +[Event] +void InvalidMethod(Activity? activity); + +// ERROR TSG1002: multiple logging attributes +[Info] +[Warning] +void InvalidMethod(string message); + +// ERROR TSG1002: multiple metric attributes +[Counter] +[Histogram] +void InvalidMethod([InstrumentMeasurement]int value); +``` + +## Excluding parameters from specific targets + +Use `[ExcludeTargets(Targets.X)]` on a parameter to exclude it from specific telemetry families: + +```csharp +[ActivitySource("PaymentService")] +[Logger] +[Meter] +interface IPaymentTelemetry +{ + [Activity] + [Info] + [Counter] + Activity? ProcessingPayment( + [Baggage]Guid paymentId, + + // Exclude the verbose message from metrics (would be wasted as a tag) + [ExcludeTargets(Targets.Metrics)] + string processingMessage, + + // Measurement only applies to metrics + [InstrumentMeasurement] + decimal amount, + + // Exclude internal details from Activity baggage + [ExcludeTargets(Targets.Activities)] + [Tag] // still included in logs and metrics + string internalReference + ); +} +``` + +### The `Targets` enum + +```csharp +[Flags] +public enum Targets +{ + None = 0, + Activities = 1, + Logging = 2, + Metrics = 4, + All = Activities | Logging | Metrics +} +``` + +```csharp +// Exclude from multiple targets +[ExcludeTargets(Targets.Activities | Targets.Metrics)] +string loggingOnlyParameter; + +// Exclude from a single target +[ExcludeTargets(Targets.Logging)] +int metricsAndActivityParameter; +``` + +## Inference is disabled with multi-targeting + +When an interface has **multiple** class-level attributes (`[ActivitySource]`, `[Logger]`, `[Meter]`), inference is disabled and every method must declare its targets explicitly: + +```csharp +[ActivitySource("MyApp")] +[Logger] +[Meter] +interface IMyTelemetry +{ + // ERROR TSG1001: no explicit attribute + void ProcessItem(int id); + + // ✅ CORRECT: explicit attribute + [Info] + void ProcessItem(int id); + + // ✅ CORRECT: excluded from generation + [Exclude] + void ProcessItem(int id); +} +``` + +Single-target interfaces (only one class-level attribute) keep inference — see [Activities](Activities.md#inferring-method-type) and [Logging](Logging.md). + +## Return types + +- **Activity + other targets** — return `Activity?` so callers can dispose and reuse the Activity: + + ```csharp + [Activity] + [Info] + [AutoCounter] + Activity? ProcessingOrder(int orderId); + ``` + +- **Log + Metric (no Activity)** — return `void` or `IDisposable?`: + + ```csharp + [Info] + [Histogram] + void RecordOperation([InstrumentMeasurement]int duration, string operation); + + [Info] + [AutoCounter] + IDisposable? ProcessingBatch(int batchId); // scoped log + counter + ``` + +- **Event/Context + Log** — return `void`: + + ```csharp + [Event] + [Debug] + void OrderCompleted(Activity? activity, decimal total); + ``` + +## Common patterns + +### Complete observability method + +```csharp +[Activity] // distributed tracing +[Info] // structured logging +[AutoCounter] // count occurrences +Activity? ProcessingRequest( + [Baggage]string requestId, + [Tag]string endpoint, + [Tag]string method +); +``` + +### Event with context logging + +```csharp +[Event] +[Debug] +void StepCompleted( + Activity? activity, + [Tag]string stepName, + [Tag]int duration +); +``` + +### Selective parameter usage + +```csharp +[Activity] +[Info] +[Counter] +Activity? ApiCall( + [Baggage]string traceId, // Activity baggage only + [ExcludeTargets(Targets.Metrics)] + string verboseMessage, // Activity + Log only + [InstrumentMeasurement] + [ExcludeTargets(Targets.Activities | Targets.Logging)] + int callCount, // Metrics only + [Tag]string endpoint // All three +); +``` + +## Benefits + +1. **Less code** — one method definition generates multiple telemetry types +2. **Consistency** — the same parameters feed every telemetry type +3. **Atomicity** — all telemetry emitted together +4. **Maintainability** — change once, affects all telemetry types +5. **Performance** — a single method call instead of several + +## Diagnostics + +| Diagnostic | When | Resolution | +| --- | --- | --- | +| `TSG1001` | Method has no explicit attribute on a multi-target interface | Add `[Activity]`, `[Info]`, `[Counter]`, etc., or `[Exclude]` | +| `TSG1002` | Multiple attributes from the same family on one method | Use only one Activity, Logging, or Metrics attribute per method | +| `TSG1006` | `[ExcludeTargets]` references a target not present on the method | Remove `[ExcludeTargets]` or add the target attribute to the method | +| `TSG1007` | `[ExcludeTargets]` leaves an invalid parameter set for a target | Adjust exclusions so valid parameters remain for each target | + +## See also + +- [Activities](Activities.md) — Activity generation details +- [Logging](Logging.md) — logging generation details +- [Metrics](Metrics.md) — metrics generation details +- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) — `[Tag]`, `[Baggage]`, `[ExcludeTargets]` +- [Diagnostics](Diagnostics.md) — error codes and resolutions \ No newline at end of file diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md new file mode 100644 index 00000000..9828e359 --- /dev/null +++ b/docs/wiki/Performance.md @@ -0,0 +1,140 @@ +# Performance + +Full cross-runtime benchmark results for the Purview Telemetry Source Generator, generated by [BenchmarkDotNet](https://benchmarkdotnet.org/) from the [`benchmarks/`](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/benchmarks) project. + +## Reproducing + +```bash +dotnet run --project benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj \ + --configuration Release --framework net10.0 +``` + +Results are written to `BenchmarkDotNet.Artifacts/results/` as `*-report-github.md`, `*.csv`, and `*.html`. + +> [!IMPORTANT] +> BenchmarkDotNet numbers are only meaningful from **Release** builds. The tables below reflect the latest published run. Regenerate them with `just benchmark-docs` before relying on them for a specific machine/SDK combination. + +## Environment + +``` +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8117/25H2/2025Update) +13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores +.NET SDK 10.0.201 + .NET 10.0 : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3 + .NET 8.0 : .NET 8.0.25 (8.0.25, 8.0.2526.11203), X64 RyuJIT x86-64-v3 + .NET 9.0 : .NET 9.0.14 (9.0.14, 9.0.1426.11910), X64 RyuJIT x86-64-v3 +``` + +> **Note:** .NET Framework targets did not produce results in this run and are excluded from the tables below. + +## Activities + +**Source:** `ActivityBenchmarks` + +Compares the source-generator-produced `ActivityOnlyTelemetryCore` against a hand-written `ManualActivityTelemetry` under two conditions: no listener registered (fast-path) and a full-sampling `ActivityListener` active (production path). + +| Method | Runtime | HasListener | Mean | Ratio | Allocated | Alloc Ratio | +| --- | --- | --- | --- | --- | --- | --- | +| Manual: start + complete | .NET 10.0 | False | 0.56 ns | 1.00 | - | NA | +| Generated: start + complete | .NET 10.0 | False | 0.55 ns | 0.99 | - | NA | +| Manual: start + fail | .NET 10.0 | False | 0.72 ns | 1.29 | - | NA | +| Generated: start + fail | .NET 10.0 | False | 0.52 ns | 0.93 | - | NA | +| Manual: start + complete | .NET 8.0 | False | 0.71 ns | 1.00 | - | NA | +| Generated: start + complete | .NET 8.0 | False | 0.71 ns | 1.01 | - | NA | +| Manual: start + fail | .NET 8.0 | False | 0.91 ns | 1.30 | - | NA | +| Generated: start + fail | .NET 8.0 | False | 0.90 ns | 1.28 | - | NA | +| Manual: start + complete | .NET 9.0 | False | 0.54 ns | 1.00 | - | NA | +| Generated: start + complete | .NET 9.0 | False | 0.55 ns | 1.01 | - | NA | +| Manual: start + fail | .NET 9.0 | False | 0.73 ns | 1.36 | - | NA | +| Generated: start + fail | .NET 9.0 | False | 0.70 ns | 1.30 | - | NA | +| **Manual: start + complete** | **.NET 10.0** | **True** | **217.75 ns** | **1.00** | **1008 B** | **1.00** | +| Generated: start + complete | .NET 10.0 | True | 204.03 ns | 0.94 | 1008 B | 1.00 | +| Manual: start + fail | .NET 10.0 | True | 198.43 ns | 0.91 | 920 B | 0.91 | +| Generated: start + fail | .NET 10.0 | True | 189.26 ns | 0.87 | 920 B | 0.91 | +| Manual: start + complete | .NET 8.0 | True | 241.11 ns | 1.00 | 1008 B | 1.00 | +| Generated: start + complete | .NET 8.0 | True | 250.49 ns | 1.04 | 1008 B | 1.00 | +| Manual: start + fail | .NET 8.0 | True | 223.87 ns | 0.93 | 920 B | 0.91 | +| Generated: start + fail | .NET 8.0 | True | 222.14 ns | 0.92 | 920 B | 0.91 | +| Manual: start + complete | .NET 9.0 | True | 216.84 ns | 1.00 | 1008 B | 1.00 | +| Generated: start + complete | .NET 9.0 | True | 214.30 ns | 0.99 | 1008 B | 1.00 | +| Manual: start + fail | .NET 9.0 | True | 200.43 ns | 0.92 | 920 B | 0.91 | +| Generated: start + fail | .NET 9.0 | True | 222.14 ns | 1.03 | 920 B | 0.91 | + +**Interpretation:** Generated activities match or outperform hand-written code and allocate identically across all tested runtimes. + +## Logging + +**Source:** `LoggerBenchmarks` + +Compares three logging approaches: hand-written `LoggerMessage.Define` (gold-standard manual), generated v1 (`LoggerMessage.Define` pattern), and generated v2 (state-based `ThreadLocalState` pattern). + +| Method | Runtime | HasLogging | Mean | Ratio | Allocated | +| --- | --- | --- | --- | --- | --- | +| Manual: LoggerMessage.Define — single Info | .NET 10.0 | False | 0.21 ns | 1.01 | - | +| Generated v1 — single Info | .NET 10.0 | False | 0.18 ns | 0.89 | - | +| Generated v2 — single Info | .NET 10.0 | False | 0.21 ns | 1.00 | - | +| **Manual: LoggerMessage.Define — single Info** | **.NET 10.0** | **True** | **4.29 ns** | **1.00** | **-** | +| Generated v1 — single Info | .NET 10.0 | True | 4.24 ns | 0.99 | - | +| Generated v2 — single Info | .NET 10.0 | True | 4.20 ns | 0.98 | - | +| Manual: LoggerMessage.Define — full lifecycle | .NET 10.0 | True | 17.73 ns | 4.13 | - | +| Generated v1 — full lifecycle | .NET 10.0 | True | 19.52 ns | 4.55 | - | +| Generated v2 — full lifecycle | .NET 10.0 | True | 18.81 ns | 4.38 | - | +| Manual: LoggerMessage.Define — single Info | .NET 8.0 | True | 7.57 ns | 1.00 | - | +| Generated v1 — single Info | .NET 8.0 | True | 7.34 ns | 0.97 | - | +| Generated v2 — single Info | .NET 8.0 | True | 7.26 ns | 0.96 | - | +| Manual: LoggerMessage.Define — full lifecycle | .NET 8.0 | True | 28.73 ns | 3.79 | - | +| Generated v1 — full lifecycle | .NET 8.0 | True | 29.90 ns | 3.95 | - | +| Generated v2 — full lifecycle | .NET 8.0 | True | 29.85 ns | 3.94 | - | +| Manual: LoggerMessage.Define — single Info | .NET 9.0 | True | 6.10 ns | 1.00 | - | +| Generated v1 — single Info | .NET 9.0 | True | 6.22 ns | 1.02 | - | +| Generated v2 — single Info | .NET 9.0 | True | 6.25 ns | 1.03 | - | +| Manual: LoggerMessage.Define — full lifecycle | .NET 9.0 | True | 23.88 ns | 3.92 | - | +| Generated v1 — full lifecycle | .NET 9.0 | True | 24.55 ns | 4.03 | - | +| Generated v2 — full lifecycle | .NET 9.0 | True | 24.75 ns | 4.06 | - | + +**Interpretation:** Generated v1 and v2 both allocate **zero bytes** across all runtimes. On .NET 10.0 with logging active, v1 (4.24 ns) and v2 (4.20 ns) are within ~1–2% of the manual `LoggerMessage.Define` baseline (4.29 ns). + +## Multi-target + +**Source:** `MultiTargetVsSingleTargetBenchmarks` + +Measures the overhead of emitting Activity + Logging + Metrics from a single method call (multi-target) vs. Activity-only (single-target), comparing generated and manual code. + +| Method | Runtime | HasListener | Mean | Ratio | Allocated | Alloc Ratio | +| --- | --- | --- | --- | --- | --- | --- | +| **Single-target (generated): start + complete** | **.NET 10.0** | **True** | **203.33 ns** | **1.00** | **1008 B** | **1.00** | +| Multi-target (generated): start + complete | .NET 10.0 | True | 229.91 ns | 1.13 | 1032 B | 1.02 | +| Multi-target (manual): start + complete | .NET 10.0 | True | 233.48 ns | 1.15 | 1032 B | 1.02 | +| Multi-target (generated): start + complete + record latency | .NET 10.0 | True | 224.27 ns | 1.10 | 1032 B | 1.02 | +| Multi-target (manual): start + complete + record latency | .NET 10.0 | True | 217.24 ns | 1.07 | 1032 B | 1.02 | + +**Interpretation:** When an Activity listener is active (production path), multi-target generation adds ~13% overhead over single-target Activity-only on .NET 10.0, matching hand-written multi-target code within ~2%. See the [Generated Output](Generated-Output.md) page for what the multi-target implementation looks like. + +## Metrics + +**Source:** `MetricsBenchmarks` and `TagListBenchmarks` + +All instruments are **0 allocations**. Manual baselines are JIT-eliminated on .NET 10.0 (no active listener), so absolute times are shown for generated instruments; on .NET 8/9 generated and manual are within ~25%. + +| Scenario | Generated | Notes | +| --- | --- | --- | +| auto-counter (0 tags) | 0.37 ns | - | +| auto-counter (1 tag) | 0.37 ns | - | +| up-down counter | 0.35 ns | - | +| histogram (0 tags) | 0.36 ns | - | +| histogram (1 tag) | 0.36 ns | - | +| 4+ tags (TagList) | 4–7 ns | Stack-allocated `TagList` | + +The source generator uses a tag-count optimization: methods with fewer than 4 tags pass inline `KeyValuePair` parameters (no heap allocation), while methods with 4 or more tags use a stack-allocated [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist) struct. The TagList path costs 12–19× more CPU than the inline path on .NET 10.0, but both remain in the single-digit-nanosecond range with zero allocations. + +## Observable instruments + +`ObservableCounter`, `ObservableGauge`, and `ObservableUpDownCounter` are **not benchmarked** because they have no per-operation hot path to compare. They register a callback once at construction time (via `meter.CreateObservable*`) and are polled by the metrics collection pipeline — the generator produces the identical `CreateObservable*` call you would write by hand, so there is no wrapper layer on the measurement path. + +## Raw results + +Full CSV and HTML benchmark artifacts are available in [`BenchmarkDotNet.Artifacts/results/`](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/BenchmarkDotNet.Artifacts/results). + +## See also + +- [README.md § Performance](https://github.com/purview-dev/telemetry-sourcegenerator#performance) — condensed summary of the key .NET 10.0 numbers \ No newline at end of file diff --git a/docs/wiki/Refactorings.md b/docs/wiki/Refactorings.md new file mode 100644 index 00000000..faaa6178 --- /dev/null +++ b/docs/wiki/Refactorings.md @@ -0,0 +1,113 @@ +# Refactorings + +The NuGet package ships four Visual Studio code refactorings alongside the source generator. Right-click a class containing hand-written telemetry and choose the relevant conversion — the refactoring creates a generated telemetry interface and rewrites the class to use it. + +## Available refactorings + +| Refactoring | Converts | Generated interface | +| --- | --- | --- | +| **Convert ILogger to I{ClassName}Logs** | Hand-written `ILogger`/`ILogger` fields, properties, constructor parameters, and `Log*`/`Log` calls | `[Logger]` interface | +| **Convert ActivitySource to I{ClassName}Tracing** | `ActivitySource` fields/properties/parameters and `StartActivity(...)` calls | `[ActivitySource]` interface | +| **Convert Metrics to I{ClassName}Metrics** | `Counter`, `Histogram`, `UpDownCounter` fields/properties and `.Add(...)`/`.Record(...)` calls | `[Meter]` interface | +| **Convert all telemetry to I{ClassName}Telemetry** | Any combination of the above in the same class | Single `[ActivitySource]`+`[Logger]`+`[Meter]` interface | + +Each refactoring offers scope options: + +- **In this class** — converts the selected class only +- **In this document** — converts all matching classes in the file +- **In this project** — converts all matching classes in the project +- **In this solution** — converts all matching classes in the solution + +## Convert ILogger to I{ClassName}Logs + +- `LogTrace` → `[Trace]`, `LogDebug` → `[Debug]`, `LogInformation` → `[Info]`, `LogWarning` → `[Warning]`, `LogError` → `[Error]`, `LogCritical` → `[Critical]`; `Log(LogLevel.X, ...)` maps to the matching semantic attribute (unmapped levels fall back to `[Log(LogLevel.X, ...)]`). +- Literal message templates are embedded in the attribute: `[Info("Getting weather for {City}")]`. +- Literal `int` event IDs are embedded: `[Info(42, "...")]`. +- `Exception` parameters become an `exception` parameter of type `System.Exception`. +- Method names derive from the message-template words (PascalCased), deduplicated with numeric suffixes. +- Multiple logger fields/parameters on one constructor are consolidated into a single canonical injection. + +The refactoring fires when a class contains at least one `ILogger`/`ILogger` member **and** at least one recognized `Log*`/`Log` call on it. + +## Convert ActivitySource to I{ClassName}Tracing + +- Each `StartActivity("name")` call becomes an interface method returning `Activity?`, decorated `[Activity]` (for kind `Internal`/unspecified) or `[Activity(ActivityKind.X)]` for explicit kinds. +- Method names derive from the activity-name string, PascalCased (for example, `"get-weather"` → `GetWeather`); duplicates get numeric suffixes. +- `ActivitySource`-typed fields/properties/parameters are rewritten to the interface type, and `StartActivity(...)` invocations become interface method calls. + +The refactoring fires when a class has at least one `ActivitySource` member **and** at least one `.StartActivity(...)` call on it. + +## Convert Metrics to I{ClassName}Metrics + +- `Counter.Add(1)` (literal 1) → `[AutoCounter]` method with no parameters. +- `Counter.Add(value)` → `[Counter] void Method(T value)`. +- `Histogram.Record(value)` → `[Histogram] void Method(T value)`. +- `UpDownCounter.Add(value)` → `[UpDownCounter] void Method(T value)`. +- Additional tag arguments become `string tag1`, `string tag2`, ... parameters. +- Method names derive from the field name with instrument suffixes (`UpDownCounter`, `Histogram`, `Counter`, `Gauge`, `Meter`) stripped, then PascalCased. +- The `Meter` itself is not converted — the constructor's `Meter`/`IMeterFactory` usage and `CreateCounter` registration calls remain. + +The refactoring fires when a class has at least one metrics instrument member **and** at least one `.Add(...)`/`.Record(...)` call on it. + +> [!NOTE] +> `System.Diagnostics.Metrics` is not available on .NET Framework, so the metrics refactoring is not available for those target frameworks. + +## Convert all telemetry to I{ClassName}Telemetry + +Composes the per-family conversions into a single interface decorated with `[ActivitySource]`, `[Logger]`, and/or `[Meter]` for only the families actually present (multi-targeting). It fires when a class uses any combination of logger/ActivitySource/metrics members **and** has at least one corresponding call. + +## What the refactorings produce + +```csharp +using Purview.Telemetry; + +[Logger] +public interface IOrderServiceLogs +{ + [Info] + void OrderPlaced(int orderId, string customerName); +} +``` + +The original class is rewritten to use the interface: + +```csharp +public class OrderService(IOrderServiceLogs logger) +{ + public void PlaceOrder(int orderId, string customerName) + { + logger.OrderPlaced(orderId, customerName); + } +} +``` + +## Manual fallback + +If a refactoring does not cover a call pattern, convert it manually: + +1. Identify the telemetry type (Logging, Activity, Metric). +2. Create a new interface with the matching class-level attribute (`[Logger]`, `[ActivitySource]`, `[Meter]`). +3. Add a method for each distinct operation, using the method-level attribute from the mapping below. +4. Replace the hand-written call with the interface method. +5. Register the interface in DI with `services.Add{InterfaceNameWithoutI}()`. + +| Hand-written telemetry | Generated attribute | +| --- | --- | +| `ILogger.LogInformation(...)` | `[Info]` | +| `ILogger.LogDebug(...)` | `[Debug]` | +| `ILogger.LogTrace(...)` | `[Trace]` | +| `ILogger.LogWarning(...)` | `[Warning]` | +| `ILogger.LogError(...)` | `[Error]` | +| `ILogger.LogCritical(...)` | `[Critical]` | +| `ActivitySource.StartActivity(...)` | `[Activity]` | +| `activity.AddEvent(...)` | `[Event]` | +| `activity.AddBaggage(...)` / `SetBaggage(...)` | `[Context]` with `[Baggage]` parameter | +| `activity.SetTag(...)` | `[Context]` with `[Tag]` parameter | +| `Counter.Add(...)` | `[Counter]`, or `[AutoCounter]` for `Add(1)` | +| `Histogram.Record(...)` | `[Histogram]` | +| `UpDownCounter.Add(...)` | `[UpDownCounter]` | + +## See also + +- [Migration from ILogger](Migration-From-ILogger.md) +- [Migration from Activities](Migration-From-Activities.md) \ No newline at end of file diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md new file mode 100644 index 00000000..6efce155 --- /dev/null +++ b/docs/wiki/Release-Flow.md @@ -0,0 +1,62 @@ +# Release Flow + +Releases are driven by GitHub Actions using the reusable [`purview-dev/build`](https://github.com/purview-dev/build) pipelines. No release steps are performed manually. + +```text +Feature branch + → PR to main (pr.yml runs purview-build.yml: restore + build + lint + tests) + → merge to main (release.yml runs purview-release.yml: build, test, pack, publish) + → GitHub Release (NuGet package + changelog, created by the pipeline) +``` + +## Workflow components + +| Component | File | Purpose | +| --- | --- | --- | +| PR gate | `.github/workflows/pr.yml` | Runs the reusable `purview-dev/build` `purview-build.yml` pipeline (restore, build, lint, tests) and builds/tests the sample solution. | +| CD pipeline | `.github/workflows/release.yml` | Runs the reusable `purview-dev/build` `purview-release.yml` pipeline on push to `main`. | +| Local pipeline | `Justfile` `pipeline-*` recipes | Mirror the CI/CD pipelines locally via the `Purview.Build` tool (`.tools/purview-build`). | + +## Developer workflow + +1. Create a feature branch and make your changes. +2. Validate locally with `just pipeline-pr` (restore, build, lint, tests — the same gate CI enforces). +3. Push and open a PR to `main`. `pr.yml` must pass (build, lint, tests, plus the sample build/test job). +4. Merge the PR into `main`. +5. The release is published automatically — `release.yml` restores and builds the main and sample solutions, runs the integration tests, packs the NuGet package, publishes it, and creates a GitHub Release. + +## Versioning + +- The version lives in `package.json`. **Current Version:** 5.0.0-prerelease.8 +- It is applied to `Version`/`PackageVersion` by `Purview.DotNetProjectSdk` via package.json version detection. +- `just version` prints the current version. +- After bumping `package.json`, run `just update-version` to sync the version into docs/samples. + +## Building the package locally + +| Command | Purpose | +| --- | --- | +| `just pack` | Updates the version, then packs the NuGet package into `artifacts/`. | +| `just pipeline-local-release` | Packs and publishes to a local NuGet feed (see the Justfile note on argument quoting for the feed path). | +| `just pipeline-release` | Full release pipeline (build, test, pack, publish, GitHub release). | + +## Pre-release validation + +Before a release, validate locally: + +1. `just pipeline-pr` — restore, build, lint, tests. +2. `just build-s && just test-s` — sample solution build and tests. +3. `just pipeline-local-release` — confirm the pack + local publish succeed. + +## Releasing + +1. Bump the version in `package.json`. +2. Run `just update-version` to sync docs/samples. +3. Push a PR with the version bump; merge to `main`. +4. `release.yml` publishes the release automatically. + +> The legacy `scripts/setup-release.*` files describe a changeset-based (`@changesets/cli`) flow that is **not** used by this repository. They are retained for reference only; the actual release automation lives in `.github/workflows/pr.yml` and `.github/workflows/release.yml`. + +## See also + +- [Contributing](Contributing.md) — development setup and conventions \ No newline at end of file diff --git a/docs/wiki/Sample-Application.md b/docs/wiki/Sample-Application.md new file mode 100644 index 00000000..e11989f0 --- /dev/null +++ b/docs/wiki/Sample-Application.md @@ -0,0 +1,160 @@ +# Sample Application + +The [.NET Aspire sample](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/samples/SampleApp) demonstrates Activities, Logs, and Metrics generation working together with the Aspire Dashboard. + +## Solution layout + +| Project | Purpose | +| --- | --- | +| `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.Web` | Frontend client that also generates its own telemetry. | +| `SampleApp.ServiceDefaults` | Aspire service defaults; registers the generated meter/activity source names. | + +The sample targets `net10.0` and enables `EmitCompilerGeneratedFiles`, so generated output can be inspected under: + +``` +obj//net10.0/generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/ +``` + +## Telemetry interfaces + +### `IEntityStoreTelemetry` (APIService) + +The multi-target interface from the README — one method emits an Activity, an Info log, and an AutoCounter simultaneously: + +```csharp +[ActivitySource] +[Logger] +[Meter] +interface IEntityStoreTelemetry +{ + [Activity] + [Info] + [AutoCounter] + Activity? GettingEntityFromStore(int entityId, [Baggage] string serviceUrl); + + [Event] + [Trace] + void GetDuration(Activity? activity, int durationInMS); + + [Context] + void RetrievedEntity(Activity? activity, float totalValue, int lastUpdatedByUserId); + + [Warning] + void EntityNotFound(int entityId); + + [Histogram] + void RecordEntitySize(int sizeInBytes); +} +``` + +### `IWeatherServiceTelemetry` (APIService) + +Demonstrates single-method multi-targeting, Activity status codes, and enumerable expansion: + +```csharp +[ActivitySource] +[Logger] +[Meter] +public interface IWeatherServiceTelemetry +{ + [Activity(ActivityKind.Client)] + [Trace] + Activity? GettingWeatherForecast([Baggage] string someRandomBaggageInfo, int requestedCount); + + [Event] + void ForecastReceived(Activity? activity, int minTempInC, int maxTempInC); + + [Event(ActivityStatusCode.Error)] + void FailedToRetrieveForecast(Activity? activity, Exception exception); + + [Event(ActivityStatusCode.Ok)] + void TemperaturesReceived(Activity? activity, TimeSpan elapsed); + + [AutoCounter] + [Warning] + [Event] + void ItsTooCold(Activity? activity, int minTempInC, int tooColdCount); + + [Histogram] + void HistogramOfTemperature(int temperature); + + [Error] + [AutoCounter] + void RequestedCountIsOutOfRange(int requestCount); + + [Info] + void TemperaturesWithinRange([ExpandEnumerable(maximumValueCount: 100)] int[] temperaturesInC); +} +``` + +### `IWeatherAPIClientTelemetry` (Web) + +Demonstrates `[ExcludeTargets]`, an instrument prefix, and `HttpStatusCode` tags: + +```csharp +[ActivitySource] +[Logger] +[Meter(InstrumentPrefix = "weather")] +public interface IWeatherAPIClientTelemetry +{ + [Activity(ActivityKind.Client)] + [Info] + [AutoCounter] + Activity? GetWeatherForecasts(int? count); + + [Event] + [Error] + [AutoCounter] + void FailedToGetForecast(Activity? activity, Exception ex, [ExcludeTargets(Targets.Activities)] int? count); + + [Event] + void RequestComplete(Activity? activity, HttpStatusCode statusCode, bool isSuccessStatusCode); + + [AutoCounter] + void RequestSuccess(); + + [Event] + [Warning] + void NoForecastsRecieved(Activity? activity); + + [Event(ActivityStatusCode.Ok)] + [Debug] + void ForecastsRecieved( + Activity? activity, + int forecastCount, + [ExpandEnumerable(100), ExcludeTargets(Targets.Activities)] + WeatherForecast[] weatherForecasts + ); +} +``` + +## Registering names with Aspire + +`SampleApp.ServiceDefaults` wires the generated names into Aspire's OpenTelemetry setup: + +```csharp +builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); +``` + +## 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. + +## Running + +```bash +just build-s +just test-s +``` + +With `dotnet run --project samples/SampleApp/SampleApp.AppHost`, the Aspire Dashboard shows the generated Activities, Logs, and Metrics in real time. + +## See also + +- [Getting Started](Getting-Started.md) +- [Multi-Targeting](Multi-Targeting.md) +- [Generated Output](Generated-Output.md) — real generated code from this sample \ No newline at end of file diff --git a/docs/wiki/Tags-and-Baggage.md b/docs/wiki/Tags-and-Baggage.md new file mode 100644 index 00000000..1eb1fef2 --- /dev/null +++ b/docs/wiki/Tags-and-Baggage.md @@ -0,0 +1,126 @@ +# Tags, Baggage, and Parameter Attributes + +> [!IMPORTANT] +> All attributes are now in the unified `Purview.Telemetry` namespace. + +Parameters on telemetry methods can be decorated to control how they are emitted. This page covers the parameter-level attributes. + +## `[Tag]` + +Used within [Activity](Activities.md) and [Metrics](Metrics.md) generation to add a parameter as a tag. Tag names follow the configured `NamingConvention`: + +- **OpenTelemetry** (default): `snake_case` for compound words (e.g. `"entity_id"`) +- **Legacy**: lowercased, smashed (e.g. `"entityid"`) + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `Name` | `string?` | `null` | Explicitly sets the tag name. When `null`, the parameter name is used (transformed according to the naming convention). | +| `SkipOnNullOrEmpty` | `bool` | `false` | When `true`, the tag is not added if the parameter value is `null` or default. | + +### Examples + +```csharp +using Purview.Telemetry; + +[ActivitySource("OrderService")] +interface IOrderTelemetry +{ + [Activity] + Activity? ProcessingOrder( + // Auto-named tag (becomes "order_id" in OpenTelemetry mode) + [Tag]int orderId, + + // Explicitly named tag (explicit names are not transformed) + [Tag(Name = "customer.name")]string customerName, + + // Skip if null + [Tag(SkipOnNullOrEmpty = true)]string? notes + ); +} +``` + +**OpenTelemetry convention (default):** + +```csharp +[Tag]int orderId // Generated: "order_id" +[Tag]string userName // Generated: "user_name" +[Tag(Name = "my.custom.tag")]int value // Generated: "my.custom.tag" (not transformed) +``` + +**Legacy convention:** + +```csharp +[Tag]int orderId // Generated: "orderid" +[Tag]string userName // Generated: "username" +``` + +To change the convention, see [Naming conventions](Generation.md#naming-conventions). + +### Best practices + +1. **Use explicit names for cross-service tags** to prevent breakage if parameter names change: + + ```csharp + [Tag(Name = "trace.id")]string traceId + ``` + +2. **Use `SkipOnNullOrEmpty` for optional tags** to avoid cluttering telemetry with null values: + + ```csharp + [Tag(SkipOnNullOrEmpty = true)]string? optionalContext + ``` + +3. **Follow OpenTelemetry semantic conventions** for standard names such as `http.method`, `http.status_code`, `service.name`. + +## `[Baggage]` + +Marks a parameter as baggage on an Activity or ActivityEvent. Baggage propagates across service boundaries, unlike tags. + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `Name` | `string?` | `null` | Explicitly sets the baggage name. When `null`, the parameter name is used. | +| `SkipOnNullOrEmpty` | `bool` | `false` | When `true`, the parameter is skipped when `null` or default. | + +> [!NOTE] +> `[Baggage]` parameters should be `string`; `TSG3000` warns if a non-string is used (`ToString()` is called). + +## `[ExcludeTargets]` + +Excludes a parameter from specific telemetry targets. See [Multi-Targeting](Multi-Targeting.md) for the `Targets` enum values (`None`, `Activities`, `Logging`, `Metrics`, `All`). + +| Property | Type | Description | +| --- | --- | --- | +| `ExcludedTargets` | `Targets` | The targets to exclude the parameter from. Available on construction. | + +```csharp +[ExcludeTargets(Targets.Metrics)] +string verboseMessage; // excluded from metrics only +``` + +## `[ExpandEnumerable]` + +Applied to an array or `IEnumerable` parameter on a log method, it logs the individual elements. See [Logging Generation v2](Logging-Generation-v2.md#expandenumerable). + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `MaximumValueCount` | `int` | `5` | The maximum number of elements to output from the enumeration/array. | + +## `[InstrumentMeasurement]` + +Marks the parameter used as the instrument measurement value on a metrics method. See [Metrics](Metrics.md#the-measurement-value). + +## `[Escape]` + +Marks a `bool` parameter as the escape value for an exception event on an Event method. See [Activities](Activities.md#escape). + +## `[StatusDescription]` + +Marks a `string` parameter as the status description for an Event that sets an error status code. See [Activities](Activities.md#statusdescription). + +## See also + +- [Activities](Activities.md) — tags/baggage in Activity generation +- [Metrics](Metrics.md) — tags in metrics generation +- [Multi-Targeting](Multi-Targeting.md) — excluding parameters per target +- [Generation](Generation.md) — naming conventions +- [Breaking Changes](Breaking-Changes.md#opentelemetry-aligned-naming) — v3 to v4 naming changes \ No newline at end of file diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md new file mode 100644 index 00000000..8aecde2c --- /dev/null +++ b/docs/wiki/Testing.md @@ -0,0 +1,66 @@ +# Testing + +Telemetry interfaces are plain interfaces, so they are easy to mock or substitute in unit tests. Because the generated implementation only lives at the interface boundary, standard mocking frameworks (NSubstitute, Moq, TUnitMocks, ...) work directly. + +## Inject the interface + +```csharp +public class OrderService(IOrderServiceTelemetry telemetry) +{ + public void PlaceOrder(int orderId, string customerName) + { + using var activity = telemetry.PlacingOrder(orderId, customerName); + // ... + } +} +``` + +## Mock it in tests + +```csharp +public class OrderServiceTests +{ + [Test] + public void PlaceOrder_EmitsTelemetry() + { + var telemetry = Substitute.For(); + var service = new OrderService(telemetry); + + service.PlaceOrder(42, "Alice", "EMEA"); + + telemetry.Received().PlacingOrder(42, "Alice", "EMEA"); + } +} +``` + +```csharp +// Moq equivalent +var telemetry = new Mock(); +var service = new OrderService(telemetry.Object); +service.PlaceOrder(42, "Alice", "EMEA"); +telemetry.Verify(x => x.PlacingOrder(42, "Alice", "EMEA"), Times.Once); +``` + +## Testing real emission + +To assert against real telemetry output, set up a listener or collector: + +```csharp +using var listener = new ActivityListener +{ + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, +}; +ActivitySource.AddActivityListener(listener); + +var telemetry = Substitute.For(); +// Configure the substitute to return a real Activity when the method is called: +telemetry.PlacingOrder(Arg.Any(), Arg.Any()) + .Returns(_ => new Activity("placing-order").Start()); +``` + +For full multi-target scenarios, the [sample application](Sample-Application.md) demonstrates a TUnit + NSubstitute setup. + +## Integration tests in this repository + +The repository's own test suite lives in `src/tests/SourceGenerator.IntegrationTests` and uses `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit with the framework's `CodeQuery` syntax-lookup API and assertion extensions). See [Contributing](Contributing.md) for running them. \ No newline at end of file diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 00000000..a38ff0c7 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,23 @@ +- [Home](Home.md) +- [Getting Started](Getting-Started.md) +- [Installation](Installation.md) +- [Generation](Generation.md) +- [Activities](Activities.md) +- [Logging](Logging.md) +- [Logging Generation v1](Logging-Generation-v1.md) +- [Logging Generation v2](Logging-Generation-v2.md) +- [Metrics](Metrics.md) +- [Multi-Targeting](Multi-Targeting.md) +- [Tags, Baggage, and Parameter Attributes](Tags-and-Baggage.md) +- [Generated Output](Generated-Output.md) +- [Diagnostics](Diagnostics.md) +- [Performance](Performance.md) +- [FAQ](FAQ.md) +- [Breaking Changes](Breaking-Changes.md) +- [Refactorings](Refactorings.md) +- [Migration from ILogger](Migration-From-ILogger.md) +- [Migration from Activities](Migration-From-Activities.md) +- [Testing](Testing.md) +- [Sample Application](Sample-Application.md) +- [Contributing](Contributing.md) +- [Release Flow](Release-Flow.md) \ No newline at end of file