From c2211f38507d22e524bccad0a431c89e7e5a605b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 27 Sep 2026 14:35:23 +0100 Subject: [PATCH] fix: misrepresentation of the naming error --- Directory.Packages.props | 2 +- docs/wiki/Activities.md | 5 +- docs/wiki/Breaking-Changes.md | 10 + docs/wiki/Diagnostics.md | 21 +- docs/wiki/Tags-and-Baggage.md | 2 + package.json | 2 +- ...tySourceTargetClassEmitter.EventMethods.cs | 5 - ...vitySourceTargetClassEmitter.Parameters.cs | 38 +-- .../Helpers/DiagnosticLibrary.Activities.cs | 5 +- .../Helpers/PipelineHelpers.Activities.cs | 7 +- .../Records/ActivityRecords.cs | 10 +- .../Records/TelemetryRules.Activities.cs | 90 ++++++- ...rceGeneratorActivityTests.BasicEventGen.cs | 224 ++++++++++++++++++ ...etrySourceGeneratorTests.TelemetryBasic.cs | 4 +- 14 files changed, 389 insertions(+), 36 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 11d8a4f5..8d71bbaa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ true 5.9.0 1.67.0 - 1.0.0-prerelease.52 + 1.0.0-prerelease.53 10.0.12 10.10.0 1.18.0 diff --git a/docs/wiki/Activities.md b/docs/wiki/Activities.md index b138ffdb..5fb84ed6 100644 --- a/docs/wiki/Activities.md +++ b/docs/wiki/Activities.md @@ -55,7 +55,10 @@ When an `Exception` parameter is present, the default behaviour follows the [Ope - `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). +This behaviour can be overridden with the `EventAttribute` options (see below). The standard `exception` event is only required when the exception is recorded with the OpenTelemetry rules: naming the event `exception` with `[Event(Name = "exception")]` attaches the tags above to *that* event instead of adding a separate one, and `TSG3021` suggests doing so when the name differs. The diagnostic and the standard name do not apply when `UseRecordExceptionRules` is `false`, when the exception is a `[Baggage]` parameter, or when the parameter is excluded from the Activities target with `[ExcludeTargets(Targets.Activities)]`. + +> [!NOTE] +> `Name` on a *logging* attribute (`[Error]`, `[Log]`, `[Info]`, …) renames the **log entry** — its `EventId` name and default message template — not the activity event. The event name always comes from `[Event(Name = "…")]`. ### Context diff --git a/docs/wiki/Breaking-Changes.md b/docs/wiki/Breaking-Changes.md index c05eabb1..907b110e 100644 --- a/docs/wiki/Breaking-Changes.md +++ b/docs/wiki/Breaking-Changes.md @@ -160,6 +160,16 @@ v5 adds `MeterName` and `MeterNameGenerationType` to `[MeterGeneration]`, contro 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. +### Activities targets now honour parameter-level `[ExcludeTargets]` + +`[ExcludeTargets(Targets.Activities)]` on a parameter is now applied consistently: the parameter is no longer set as a tag, added as baggage, or used as a reserved parameter (`tags`, `parentContext`, `links`, `startTime`, `timestamp`, `[Escape]`, `[StatusDescription]`) for the Activity/ActivityEvent. It stays part of the generated method signature and is still passed to the Logging and Metrics targets. + +**Impact:** Low — the generated Activity tags/baggage change for interfaces that already use `[ExcludeTargets(Targets.Activities)]` (previously the attribute was ignored for the Activities target). Activities-specific diagnostics for the excluded parameter (`TSG3000`, `TSG3003`–`TSG3011`, `TSG3016`, `TSG3017`, `TSG3021`) are no longer raised. + +### `TSG3021` only applies when the OpenTelemetry exception rules are used + +`TSG3021` (exception event should be named `exception`) is no longer raised when the exception is not recorded using the OpenTelemetry exception rules — that is, when `[Event(UseRecordExceptionRules = false)]` is used, when the exception parameter is `[Baggage]`, or when it is excluded from the Activities target. When the `Name` was set on a *logging* attribute instead of `[Event]`, the diagnostic now says so explicitly. + ## v1 and v2 to v3 ### Logging event-name generation diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index e3d1a1ec..254dc87d 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -60,7 +60,7 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `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")]`). | +| `TSG3021` | Info | Exception event does not use OpenTelemetry standard name. An `[Event]` method records an exception under the OpenTelemetry exception rules but the event name is not the standard `"exception"` (suggest `[Event(Name = "exception")]`). Not raised when `UseRecordExceptionRules` is disabled, for `[Baggage]` exceptions, or when the exception parameter is excluded from the Activities target. | | `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) @@ -107,6 +107,25 @@ Two or more methods share the same name, which is used to generate members on th 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`. +### TSG3021 — exception event name + +Rename the event with `[Event(Name = "exception")]` so the OpenTelemetry exception tags (`exception.type`, `exception.message`, `exception.stacktrace`, `exception.escaped`) are attached to that event instead of a separate event named `exception`. + +```csharp +[Event(Name = "exception")] // ✅ the event carries the exception tags +void FailedToRetrieve(Activity? activity, Exception exception); +``` + +A `Name` set on a *logging* attribute does not name the event — it renames the log entry: + +```csharp +[Event] // ❌ event name is the method name +[Error(Name = "exception")] // the log entry is named 'exception' +void FailedToRetrieve(Activity? activity, Exception exception); +``` + +The diagnostic is not raised when the exception is not recorded using the OpenTelemetry exception rules: `[Event(UseRecordExceptionRules = false)]`, a `[Baggage]` exception parameter, or an exception parameter excluded from the Activities target (`[ExcludeTargets(Targets.Activities)]`). + ### TSG3022 — non-nullable Activity return Return `Activity?` so callers can handle the `null` case when no listeners are active. diff --git a/docs/wiki/Tags-and-Baggage.md b/docs/wiki/Tags-and-Baggage.md index 1eb1fef2..04643d28 100644 --- a/docs/wiki/Tags-and-Baggage.md +++ b/docs/wiki/Tags-and-Baggage.md @@ -97,6 +97,8 @@ Excludes a parameter from specific telemetry targets. See [Multi-Targeting](Mult string verboseMessage; // excluded from metrics only ``` +The parameter stays part of the generated method signature, but it is not applied to the excluded target: for the Activities target that means it is not set as a tag, not added as baggage, and not used as a reserved parameter such as `tags`, `parentContext`, `startTime`, or `[Escape]`. Excluding it from Activities also stops the Activities-specific diagnostics (`TSG3000`, `TSG3003`–`TSG3011`, `TSG3016`, `TSG3017`, `TSG3021`) for that parameter. + ## `[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). diff --git a/package.json b/package.json index 258c9ced..abc5ad6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-telemetry-sourcegenerator", - "version": "5.0.0-prerelease.17", + "version": "5.0.0-prerelease.18", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs index 136c2520..d2e13d15 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.EventMethods.cs @@ -147,11 +147,6 @@ static string EmitEventTags( var escapeValue = escapeParam?.ParameterName ?? "true"; foreach (var tagParam in methodTarget.Tags) { - var emitTag = - tagParam.IsException - && methodTarget.ActivityOrEventName != PropertyLibrary.Activities.Tag_ExceptionEventName - && useRecordedExceptionRules; - void EmitTag() { if (tagParam.IsException) diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs index ad937fa0..104bff01 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Parameters.cs @@ -97,29 +97,35 @@ out ActivityBasedParameterTarget? statusDescriptionParam escapeParam = null; statusDescriptionParam = null; - var activityParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.Activity) + // Parameters excluded from the Activities target are part of the generated method signature but + // are never passed to the ActivitySource, so they are not considered here. + var applicableParameters = methodTarget + .Parameters.Where(static p => !p.ExcludedFromActivities) .ToImmutableArray(); - var parentContextOrIdParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.ParentContextOrId) + + var activityParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.Activity) + .ToImmutableArray(); + var parentContextOrIdParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.ParentContextOrId) .ToImmutableArray(); - var tagsParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.TagsEnumerable) + var tagsParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.TagsEnumerable) .ToImmutableArray(); - var linksParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.LinksEnumerable) + var linksParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.LinksEnumerable) .ToImmutableArray(); - var startTimeParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.StartTime) + var startTimeParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.StartTime) .ToImmutableArray(); - var timestampParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.Timestamp) + var timestampParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.Timestamp) .ToImmutableArray(); - var escapeParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.Escape) + var escapeParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.Escape) .ToImmutableArray(); - var statusDescriptionParams = methodTarget - .Parameters.Where(m => m.ParamDestination == ActivityParameterDestination.StatusDescription) + var statusDescriptionParams = applicableParameters + .Where(m => m.ParamDestination == ActivityParameterDestination.StatusDescription) .ToImmutableArray(); if (activityParams.Length > 1) diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs index d4f7739d..bb35475f 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs @@ -236,11 +236,14 @@ public static class Activities isBlocking: false ); + // The trailing '{1}' placeholder carries an optional hint supplied by the rule (for example when + // the 'Name' was applied to a logging attribute); rules pass string.Empty when there is no + // additional context. public static readonly ReportableDiagnostic ExceptionEventNotStandardName = ReportableDiagnostic.Create( new DiagnosticDescriptor( id: "TSG3021", title: "Exception event does not use OpenTelemetry standard name", - messageFormat: "Event '{0}' records an exception but does not use the OpenTelemetry standard name 'exception'. Consider using [Event(Name = \"exception\")] for better observability.", + messageFormat: "Event '{0}' records an exception but does not use the OpenTelemetry standard name 'exception'. Consider using [Event(Name = \"exception\")] for better observability.{1}", defaultSeverity: DiagnosticSeverity.Info, category: Categories.Activity.Usage, isEnabledByDefault: true diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs index a83625da..6ec7c868 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs @@ -166,11 +166,14 @@ out var eventAttribute namingConvention, token ); + // Parameters can opt out of the Activities target to be used by the other target families + // only: they remain part of the generated method signature but are not applied to the + // Activity or ActivityEvent. var baggageParameters = parameters - .Where(m => m.ParamDestination == ActivityParameterDestination.Baggage) + .Where(m => m.ParamDestination == ActivityParameterDestination.Baggage && !m.ExcludedFromActivities) .ToImmutableArray(); var tagParameters = parameters - .Where(m => m.ParamDestination == ActivityParameterDestination.Tag) + .Where(m => m.ParamDestination == ActivityParameterDestination.Tag && !m.ExcludedFromActivities) .ToImmutableArray(); var targetGenerationState = Utilities.IsValidGenerationTarget( diff --git a/src/src/SourceGenerator/Records/ActivityRecords.cs b/src/src/SourceGenerator/Records/ActivityRecords.cs index 475ef374..6f5cafeb 100644 --- a/src/src/SourceGenerator/Records/ActivityRecords.cs +++ b/src/src/SourceGenerator/Records/ActivityRecords.cs @@ -64,7 +64,15 @@ sealed record ActivityBasedParameterTarget( bool SkipOnNullOrEmpty, bool IsException, GenerationType ExcludedTargets -); +) +{ + /// + /// True when the parameter opts out of the Activities target via + /// [ExcludeTargets(Targets.Activities)]. The parameter stays part of the generated method + /// signature (it is declared on the interface) but is never applied to the Activity or ActivityEvent. + /// + public bool ExcludedFromActivities => ExcludedTargets.HasFlag(GenerationType.Activities); +} enum ActivityParameterDestination { diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs index 93069591..2c2649d2 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Purview.Telemetry.SourceGenerator.Helpers; using Purview.Telemetry.SourceGenerator.Records; namespace Purview.Telemetry.SourceGenerator; @@ -125,7 +126,10 @@ CancellationToken token if (generateDiagnosticsForMissingActivity && method.HasActivityParameter && method.Parameters.Count > 0) { - if (method.Parameters[0].ParamDestination != ActivityParameterDestination.Activity) + // Parameters excluded from the Activities target keep their declared position in the generated + // signature, so the first applicable parameter is the one that can satisfy this rule. + var firstApplicableParameter = method.Parameters.FirstOrDefault(static p => !p.ExcludedFromActivities); + if (firstApplicableParameter?.ParamDestination != ActivityParameterDestination.Activity) diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.ActivityShouldBeTheFirstParameter.Descriptor, @@ -135,17 +139,32 @@ CancellationToken token ); } - // TSG3021: an event recording an exception should use the OpenTelemetry standard name. + // TSG3021: an event recording an exception should use the OpenTelemetry standard name. This only + // applies when the exception is recorded using the OpenTelemetry exception rules: with + // UseRecordExceptionRules disabled the exception is emitted as a plain tag, a baggage exception is + // set as baggage, and a parameter excluded from the Activities target is not applied at all. if (method.MethodType == ActivityMethodType.Event) { - var recordsException = method.Parameters.Any(static p => p.IsException); - if (recordsException && !string.Equals(method.ActivityOrEventName, "exception", StringComparison.Ordinal)) + var useRecordExceptionRules = + method.EventAttribute?.UseRecordExceptionRules + ?? PropertyLibrary.Activities.UseRecordExceptionRulesDefault; + + var recordsException = + useRecordExceptionRules + && method.Parameters.Any(static p => + p.IsException + && !p.ExcludedFromActivities + && p.ParamDestination != ActivityParameterDestination.Baggage + ); + + if (recordsException && !IsStandardExceptionEventName(method.ActivityOrEventName)) diagnostics.Add( ReportableDiagnostic.Create( DiagnosticLibrary.Activities.ExceptionEventNotStandardName.Descriptor, isBlocking: false, methodSymbol, - method.ActivityOrEventName + method.ActivityOrEventName, + GetExceptionEventNameHint(methodSymbol, token) ) ); } @@ -192,7 +211,8 @@ ImmutableArray.Builder diagnostics { var duplicateReserved = method .Parameters.Where(static p => - p.ParamDestination is not (ActivityParameterDestination.Tag or ActivityParameterDestination.Baggage) + !p.ExcludedFromActivities + && p.ParamDestination is not (ActivityParameterDestination.Tag or ActivityParameterDestination.Baggage) ) .GroupBy(static p => p.ParamDestination) .Where(static g => g.Count() > 1); @@ -225,6 +245,10 @@ CancellationToken token { token.ThrowIfCancellationRequested(); + // Only parameters applied to the Activities target are considered. + if (parameter.ExcludedFromActivities) + continue; + var location = GetParameterLocation(methodSymbol, parameter.ParameterName); var parameterName = parameter.GeneratedName; @@ -336,4 +360,58 @@ CancellationToken token #pragma warning restore IDE0010 // Add missing cases } } + + static bool IsStandardExceptionEventName(string? name) => + string.Equals(name, PropertyLibrary.Activities.Tag_ExceptionEventName, StringComparison.Ordinal); + + /// + /// Returns the optional hint appended to TSG3021. A Name on a logging attribute (such as + /// [Error]) renames the log entry, not the activity event, so surface that when it looks like + /// the name was applied to the wrong attribute. + /// + static string GetExceptionEventNameHint(IMethodSymbol methodSymbol, CancellationToken token) + { + if ( + !Utilities.TryContainsAttribute( + methodSymbol, + TypeLibrary.Purview.Telemetry.LogAttributeTargets, + token, + out var matchingType, + out var attributeData + ) + ) + return string.Empty; + + if (!IsStandardExceptionEventName(GetLogEntryName(methodSymbol, attributeData!, token))) + return string.Empty; + + return $" The Name on [{matchingType.RenderAttributeTypeName}] renames the log entry, not the activity event."; + } + + /// + /// Gets the name configured on a logging attribute. The parsed log-attribute models historically + /// resolved name from the constructor first, so an explicitly-set Name property is read + /// directly from the attribute's named arguments as well. + /// + /// + /// The framework's attribute-data model generator now prefers the named argument (see + /// sourcegenerator-framework); this fallback keeps the hint working for the pinned version. + /// + static string? GetLogEntryName(IMethodSymbol methodSymbol, AttributeData attributeData, CancellationToken token) + { + var fromModel = SharedHelpers.GetLogAttribute(methodSymbol, token)?.Name; + if (fromModel is not null) + return fromModel; + + foreach (var namedArgument in attributeData.NamedArguments) + { + if ( + string.Equals(namedArgument.Key, "Name", StringComparison.Ordinal) + && namedArgument.Value.Value is string name + ) + return name; + } + + return null; + } } diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.BasicEventGen.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.BasicEventGen.cs index cb75abb3..089f768c 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.BasicEventGen.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.BasicEventGen.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using Purview.SourceGeneratorFramework; namespace Purview.Telemetry.SourceGenerator.Activities; @@ -335,4 +336,227 @@ await Assert .IsTrue() .Because("the generated implementation must contain the second status-description event method"); } + + [Test] + [Arguments("\"exception\"")] + [Arguments("name: \"exception\"")] + [Arguments("Name = \"exception\"")] + public async Task Generate_GivenEventWithExceptionAndOpenTelemetryName_DoesNotRaiseTSG3021( + string eventAttributeArguments, + CancellationToken cancellationToken + ) + { + // Arrange: the event name can be set through the constructor (positionally or by name) or the + // Name property; all three forms must resolve to the OpenTelemetry standard name. + var basicActivity = $$""" + +using System.Diagnostics; + +namespace Testing; + +[ActivitySource("testing-activity-source")] +public interface ITestActivities +{ + [Activity] + System.Diagnostics.Activity? Activity(); + + [Event({{eventAttributeArguments}})] + void FailedToRetrieveRepositories(Activity? activity, Exception exception); +} + +"""; + + // Act + var generationResult = await GenerateAsync(basicActivity, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3021"); + } + + [Test] + public async Task Generate_GivenEventWithExceptionAndNameOnLoggingAttribute_RaisesTSG3021WithLogEntryHint( + CancellationToken cancellationToken + ) + { + // Arrange: 'Name' on a logging attribute renames the log entry, not the activity event. + const string basicTelemetry = """ + +using System.Diagnostics; + +namespace Testing; + +[ActivitySource("testing-activity-source")] +[Logger] +public interface ITestTelemetry +{ + [Activity] + [Info] + System.Diagnostics.Activity? Activity(); + + [Event] + [Error(Name = "exception")] + void FailedToRetrieveRepositories(Activity? activity, Exception exception); +} + +"""; + + // Act + var generationResult = await GenerateAsync(basicTelemetry, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).HasDiagnostic("TSG3021"); + + var message = generationResult + .AnalyzerResult!.Diagnostics.Single(diagnostic => diagnostic.Id == "TSG3021") + .GetMessage(CultureInfo.InvariantCulture); + + await Assert.That(message).Contains("The Name on [Error] renames the log entry, not the activity event"); + } + + [Test] + public async Task Generate_GivenEventWithDisabledOTelExceptionRules_DoesNotRaiseTSG3021( + CancellationToken cancellationToken + ) + { + // Arrange: with the rules disabled the exception becomes an ordinary tag, so the + // OpenTelemetry standard event name is not required. + const string basicTelemetry = """ + +using System.Diagnostics; + +namespace Testing; + +[ActivitySource("testing-activity-source")] +public interface ITestActivities +{ + [Activity] + System.Diagnostics.Activity? Activity(); + + [Event(UseRecordExceptionRules = false)] + void FailedToRetrieveRepositories(Activity? activity, Exception exception); +} + +"""; + + // Act + var generationResult = await GenerateAsync(basicTelemetry, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3021"); + } + + [Test] + public async Task Generate_GivenEventWithBaggageException_DoesNotRaiseTSG3021(CancellationToken cancellationToken) + { + // Arrange: a baggage exception is set as baggage, never recorded as an exception event. + const string basicTelemetry = """ + +using System.Diagnostics; + +namespace Testing; + +[ActivitySource("testing-activity-source")] +public interface ITestActivities +{ + [Activity] + System.Diagnostics.Activity? Activity(); + + [Event] + void FailedToRetrieveRepositories(Activity? activity, [Baggage] Exception exception); +} + +"""; + + // Act + var generationResult = await GenerateAsync(basicTelemetry, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3021"); + } + + [Test] + public async Task Generate_GivenEventWithExceptionExcludedFromActivities_DoesNotRaiseTSG3021OrRecordException( + CancellationToken cancellationToken + ) + { + // Arrange + const string basicTelemetry = """ + +using System.Diagnostics; + +namespace Testing; + +[ActivitySource("testing-activity-source")] +public interface ITestActivities +{ + [Activity] + System.Diagnostics.Activity? Activity(); + + [Event] + void FailedToRetrieveRepositories(Activity? activity, [ExcludeTargets(Targets.Activities)] Exception exception); +} + +"""; + + // Act + var generationResult = await GenerateAsync(basicTelemetry, cancellationToken: cancellationToken); + + // Assert + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3021"); + + var implClass = generationResult.Generated().GetClass("TestActivitiesCore", "Testing"); + await Assert + .That( + implClass.HasMethod( + "FailedToRetrieveRepositories", + TypeReference.Create(), + TypeReference.Create() + ) + ) + .IsTrue() + .Because("a parameter excluded from the Activities target stays in the generated signature"); + + await Assert + .That(generationResult.GetSource("TestActivitiesCore.Activity.g.cs")) + .DoesNotContain("RecordExceptionInternal(activity:") + .Because("an exception excluded from the Activities target must not be recorded on the Activity"); + } + + [Test] + public async Task Generate_GivenEventWithTagExcludedFromActivities_DoesNotSetExcludedTag( + CancellationToken cancellationToken + ) + { + // Arrange + const string basicTelemetry = """ + +using System.Diagnostics; + +namespace Testing; + +[ActivitySource("testing-activity-source")] +public interface ITestActivities +{ + [Activity] + System.Diagnostics.Activity? Activity(); + + [Event] + void Event(Activity? activity, [Tag] string kept, [ExcludeTargets(Targets.Activities)] string dropped); +} + +"""; + + // Act + var generationResult = await GenerateAsync(basicTelemetry, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("TestActivitiesCore.Activity.g.cs")) + .ContainsGeneratedCode("\"kept\""); + + await Assert + .That(generationResult.GetSource("TestActivitiesCore.Activity.g.cs")) + .DoesNotContain("\"dropped\"") + .Because("a parameter excluded from the Activities target must not be set as a tag"); + } } diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryBasic.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryBasic.cs index 9b7216ec..ed8e741f 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryBasic.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.TelemetryBasic.cs @@ -300,7 +300,9 @@ public interface ITestTelemetry var generationResult = await GenerateAsync(basicTelemetry, cancellationToken: cancellationToken); // Assert - await Assert.That(generationResult).HasDiagnostic("TSG3021"); + // With the OpenTelemetry exception rules disabled the exception is emitted as an ordinary tag, + // so the standard 'exception' event name is not required (TSG3021 does not apply). + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3021"); } [Test]