Skip to content

kjldev/purview-telemetry-sourcegenerator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

314 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Purview Telemetry Source Generator

Generates ActivitySource, ILogger, and Metrics based telemetry from methods you define on an interface.

CI

Features

  • Zero boilerplate - define methods on an interface, get 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

Supported Frameworks

  • .NET Framework 4.8
  • .NET 8 or higher

Installation

Add to your Directory.Build.props or .csproj file:

<PackageReference Include="Purview.Telemetry.SourceGenerator" Version="4.1.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>analyzers</IncludeAssets>
</PackageReference>

Quick Start

Define an interface with telemetry methods and the generator creates the implementation:

using Purview.Telemetry;

// Multi-target interface: generates Activities, Logging, AND Metrics from combined methods
[ActivitySource]
[Logger]
[Meter]
interface IEntityStoreTelemetry
{
    // MULTI-TARGET: Creates Activity + Logs Info + Increments Counter - all from one method!
    [Activity]
    [Info]
    [AutoCounter]
    Activity? GettingEntityFromStore(int entityId, [Baggage]string serviceUrl);

    // MULTI-TARGET: Adds ActivityEvent + Logs the duration as Trace.
    [Event]
    [Trace]
    void GetDuration(Activity? activity, int durationInMS);

    // Single-target examples (when you only need one telemetry type):
    
    // Activity-only: Adds Baggage to the Activity
    [Context]
    void RetrievedEntity(Activity? activity, float totalValue, int lastUpdatedByUserId);

    // Log-only: Structured log message
    [Warning]
    void EntityNotFound(int entityId);

    // Metric-only: Histogram for tracking values
    [Histogram]
    void RecordEntitySize(int sizeInBytes);
}

Register with dependency injection:

// Generated extension method
services.AddEntityStoreTelemetry();

Then inject and use - a single method call emits an Activity, Log, and Metric simultaneously:

public class EntityService(IEntityStoreTelemetry telemetry)
{
    public async Task<Entity> GetEntityAsync(int id, string serviceUrl, CancellationToken cancellationToken)
    {
        // Single call creates Activity AND logs AND increments counter
        using var activity = telemetry.GettingEntityFromStore(id, serviceUrl);
        
        var entity = await _repository.GetAsync(id, cancellationToken);
                        
        // Adds event to activity AND logs duration
        telemetry.GetDuration(activity, stopwatch.ElapsedMilliseconds);

        if (entity == null)
        {
            // Logs warning if entity not found
            telemetry.EntityNotFound(id);
            return null;
        }

        // Activity context addition
        telemetry.RetrievedEntity(activity, entity.TotalValue, entity.LastUpdatedByUserId);
        
        // Histogram only records size
        telemetry.RecordEntitySize(entity.SizeInBytes);

        return entity;
    }
}

Telemetry Types

Attribute Generation Type Description
[ActivitySource] Class-level Marks interface for Activity generation
[Activity] Method Creates and starts a new Activity
[Event] Method Adds an ActivityEvent to an Activity
[Context] Method Adds Baggage to an Activity
[Logger] Class-level Marks interface for ILogger generation
[Log] Method Generates structured log message
[Trace], [Debug], [Info], [Warning], [Error], [Critical] Method Log with specific level
[Meter] Class-level Marks interface for Metrics generation
[Counter], [AutoCounter] Method Counter instrument
[UpDownCounter] Method Up-down counter instrument
[Histogram] Method Histogram instrument
[ObservableCounter], [ObservableGauge], [ObservableUpDownCounter] Method Observable instruments

Tip

For single-target interfaces (only Activities, only Logging, or only Metrics), the generator automatically infers the necessary attributes. See the wiki for details.

Documentation

Sample Project

The .NET Aspire Sample demonstrates Activities, Logs, and Metrics generation working together with the Aspire Dashboard.

Tip

The sample project has EmitCompilerGeneratedFiles enabled so you can inspect the generated output.

Performance

Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.201. See the Performance wiki page for full cross-runtime results.

Activities (.NET 10.0)

Scenario HasListener Manual Generated Ratio
start + complete False 0.56 ns 0.55 ns 0.99x
start + complete True 218 ns / 1008 B 204 ns / 1008 B 0.94x
start + fail True 198 ns / 920 B 189 ns / 920 B 0.87x

Generated activities match or outperform hand-written code with identical allocations.

Logging (.NET 10.0)

Scenario HasLogging LoggerMessage.Define Generated v1 Generated v2
single Info call False 0.21 ns 0.18 ns 0.21 ns
single Info call True 4.29 ns 4.24 ns (0.99x) 4.20 ns (0.98x)
full lifecycle (4 calls) True 17.73 ns 19.52 ns (1.10x) 18.81 ns (1.06x)

Both v1 and v2 allocate 0 bytes per call on all runtimes. Both generated variants are within ~2% of the manual baseline for single calls; full lifecycle cost is within ~10%.

Multi-Target (.NET 10.0, Activity + Logging + Metrics)

Scenario HasListener Single-target Multi-target generated Multi-target manual
start + complete True 203 ns / 1008 B 230 ns / 1032 B (1.13x) 233 ns / 1032 B (1.15x)

Adding full Activity+Logging+Metrics multi-target generation costs ~13% over Activity-only on .NET 10.0 — matching hand-written multi-target code within 2%.

Metrics (.NET 10.0)

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

All instruments are 0 allocations. Manual baselines are JIT-eliminated on .NET 10.0 (no active listener), so absolute times are shown. On .NET 8/9, generated and manual are within ~25%.

v4 Breaking Changes

Namespace Consolidation

v4 consolidates all attributes into a single namespace. Update your using statements:

Before (v3):

using Purview.Telemetry.Activities;
using Purview.Telemetry.Logging;
using Purview.Telemetry.Metrics;

After (v4):

using Purview.Telemetry;

All attributes ([ActivitySource], [Logger], [Meter], [Activity], [Event], [Log], [Counter], etc.) are now in the unified Purview.Telemetry namespace.

OpenTelemetry-Aligned Naming (NEW in v4.0.0-alpha.5+)

v4 defaults to OpenTelemetry semantic conventions for generated telemetry names, improving observability and cross-platform compatibility. This is a breaking change if you rely on specific telemetry names.

What Changed

Telemetry Type v3 Behavior v4 Default Impact
ActivitySource Name Assembly name lowercased: "myapp" Assembly name preserved: "MyApp" ActivitySource names change casing
Tag/Baggage Keys Lowercased, smashed: "entityid" snake_case: "entity_id" Tag keys have underscores for word boundaries
Metric Instrument Names Lowercased, smashed: "recordhistogram" Hierarchical dot.separated: "myapp.products.record.histogram" Includes meter name prefix + word boundaries
Metric Tag Keys Lowercased, smashed: "requestcount" snake_case: "request_count" Metric tag keys have underscores

Examples

Before (v3/Legacy):

// Generated code:
new ActivitySource("myapp")           // lowercase
activity.SetTag("entityid", ...)      // smashed compound
var meter = meterFactory.Create("MyApp.Products");
meter.CreateCounter<int>("recordcount")  // smashed compound, no meter prefix

After (v4 OpenTelemetry mode - DEFAULT):

// Generated code:
new ActivitySource("MyApp")           // preserves casing
activity.SetTag("entity_id", ...)     // snake_case
var meter = meterFactory.Create("MyApp.Products");
meter.CreateCounter<int>("myapp.products.record.count")  // hierarchical: meter + instrument

Note: In OpenTelemetry mode, instrument names automatically include the meter name prefix (converted to lowercase dot.separated), following OpenTelemetry best practices for hierarchical metric naming.

Reverting to v3 Naming (Legacy Mode)

If you need to maintain v3-style naming for backward compatibility, set NamingConvention = Legacy on the [TelemetryGeneration] attribute:

using Purview.Telemetry;

// Revert ALL telemetry to v3 naming (assembly-level)
[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]

Or set per-interface:

// Legacy naming for this interface only
[TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]
interface IMyTelemetry { }

Available Naming Conventions

public enum NamingConvention
{
    Legacy = 0,          // v3 behaviour: lowercase, smashed compounds
    OpenTelemetry = 1    // v4 default: OTel conventions (dot.separated, snake_case)
}

Recommendation: Use NamingConvention.OpenTelemetry (default) for new projects. Only use Legacy if you need exact v3 compatibility.