Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<RoslynVersion>5.9.0</RoslynVersion>
<TUnitVersion>1.67.0</TUnitVersion>
<PurviewSGFVersion>1.0.0-prerelease.52</PurviewSGFVersion>
<PurviewSGFVersion>1.0.0-prerelease.53</PurviewSGFVersion>
<DotnetRuntimeVersion>10.0.12</DotnetRuntimeVersion>
<MSExtensionVersion>10.10.0</MSExtensionVersion>
<OpenTelemetryVersion>1.18.0</OpenTelemetryVersion>
Expand Down
5 changes: 4 additions & 1 deletion docs/wiki/Activities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/wiki/Breaking-Changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion docs/wiki/Diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/Tags-and-Baggage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/src/SourceGenerator/Helpers/PipelineHelpers.Activities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion src/src/SourceGenerator/Records/ActivityRecords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,15 @@ sealed record ActivityBasedParameterTarget(
bool SkipOnNullOrEmpty,
bool IsException,
GenerationType ExcludedTargets
);
)
{
/// <summary>
/// True when the parameter opts out of the Activities target via
/// <c>[ExcludeTargets(Targets.Activities)]</c>. The parameter stays part of the generated method
/// signature (it is declared on the interface) but is never applied to the Activity or ActivityEvent.
/// </summary>
public bool ExcludedFromActivities => ExcludedTargets.HasFlag(GenerationType.Activities);
}

enum ActivityParameterDestination
{
Expand Down
90 changes: 84 additions & 6 deletions src/src/SourceGenerator/Records/TelemetryRules.Activities.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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)
)
);
}
Expand Down Expand Up @@ -192,7 +211,8 @@ ImmutableArray<ReportableDiagnostic>.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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);

/// <summary>
/// Returns the optional hint appended to TSG3021. A <c>Name</c> on a logging attribute (such as
/// <c>[Error]</c>) renames the log entry, not the activity event, so surface that when it looks like
/// the name was applied to the wrong attribute.
/// </summary>
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.";
}

/// <summary>
/// Gets the name configured on a logging attribute. The parsed log-attribute models historically
/// resolved <c>name</c> from the constructor first, so an explicitly-set <c>Name</c> property is read
/// directly from the attribute's named arguments as well.
/// </summary>
/// <remarks>
/// The framework's attribute-data model generator now prefers the named argument (see
/// <c>sourcegenerator-framework</c>); this fallback keeps the hint working for the pinned version.
/// </remarks>
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;
}
}
Loading
Loading