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
9 changes: 8 additions & 1 deletion framework/docs/aspects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Request → [Trace] → [Log] → [UnitOfWork] → Method → [UnitOfWork] → [
Integrates with OpenTelemetry to create spans for method execution.

```csharp
using BBT.Aether.Telemetry;

// Basic span creation
[Trace]
public async Task ProcessAsync() { }
Expand All @@ -77,6 +79,10 @@ public async Task ProcessAsync() { }
[Trace(Mode = TracingMode.Span)] // Creates new span (default)
[Trace(Mode = TracingMode.Event)] // Adds events to current span
[Trace(Mode = TracingMode.Enrich)] // Enriches current span with tags

// Trace aspects represent application/business spans in both profiles
[Trace]
public async Task ExecuteDiagnosticStepAsync() { }
```

**Properties:**
Expand All @@ -85,6 +91,8 @@ public async Task ProcessAsync() { }
- `OperationName` - Custom span name (default: ClassName.MethodName)
- `Tags` - Custom tags in "key:value" format

`[Trace]` annotations represent application/business spans and are emitted in both `Business` and `Verbose`. In the `Business` profile, completed spans whose final display name starts with `[` are filtered as ordered pipeline-step detail. `Telemetry:Tracing:DetailLevel` also controls diagnostic instrumentation such as EF Core, distributed cache/lock, and low-level client spans globally.

### [Log] - Structured Logging

Adds method entry/exit logging with performance tracking and enrichment.
Expand Down Expand Up @@ -260,4 +268,3 @@ public class OrderAppService

- [Unit of Work](../unit-of-work/README.md) - Detailed UoW documentation
- [Telemetry](../telemetry/README.md) - OpenTelemetry setup

7 changes: 6 additions & 1 deletion framework/docs/telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ Config section key: `Telemetry` or `Aether:Telemetry`.
"Protocol": "http/protobuf"
},
"Tracing": {
"DetailLevel": "Business",
"EnableAspNetCore": true,
"EnableHttpClient": true,
"EnableEntityFrameworkCore": true,
"AdditionalSources": ["MyApp.*"],
"ExcludedPaths": ["/health", "/metrics"],
"Headers": ["x-correlation-id", "x-request-id"],
Expand Down Expand Up @@ -105,6 +107,7 @@ Config section key: `Telemetry` or `Aether:Telemetry`.
}
```

- **Tracing.DetailLevel**: `Business` is the default and keeps service boundaries and `[Trace]` application/business spans while suppressing diagnostic instrumentation such as EF Core, distributed cache/lock, low-level Dapr calls, and spans whose final display name starts with `[` (ordered pipeline-step detail). `Verbose` keeps all configured instrumentation and must be selected explicitly.
- **Tracing.Headers**: Request header names added as activity tags on spans (no wildcard).
- **Logging.Enrichers**: CustomAttributes and Headers are added as attributes to **all** log records (via EnricherLogProcessor) and to the HTTP body log event (via middleware scope). Header values in the sensitive list are redacted.
- **Logging.Body**: Options for HTTP request/response body logging when `UseHttpBodyLogging()` is used. Path exclusion uses `Logging.ExcludedPaths`.
Expand Down Expand Up @@ -143,6 +146,9 @@ var response = await _httpClient.GetAsync("https://api.example.com/products");
var product = await _repository.GetAsync(id);
```

Custom static instrumentation can use `AetherTracingRuntime.IsVerbose` to
follow the same globally configured profile.

### Custom Spans with Aspects

```csharp
Expand Down Expand Up @@ -508,4 +514,3 @@ public class OrderServiceTests
- **[Application Services](../application-services/README.md)** - Automatically traced
- **[Unit of Work](../unit-of-work/README.md)** - Transaction spans
- **[Distributed Events](../distributed-events/README.md)** - Context propagation

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using System.Diagnostics;
using OpenTelemetry;

namespace BBT.Aether.AspNetCore.Telemetry;

/// <summary>
/// Removes pipeline-detail spans from the export path when the Business tracing profile is active.
/// </summary>
/// <remarks>
/// Pipeline steps assign their final display names while the activity is running, so this filter
/// must run on completion rather than at sampling time. Clearing the recorded flag allows the
/// standard OpenTelemetry activity export processors to skip the span while preserving its
/// in-process activity context for any child operations created during execution.
/// </remarks>
internal sealed class BusinessSpanFilterProcessor : BaseProcessor<Activity>
{
public override void OnEnd(Activity activity)
{
ArgumentNullException.ThrowIfNull(activity);

if (activity.DisplayName.StartsWith("[", StringComparison.Ordinal))
{
activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using BBT.Aether.Telemetry;

namespace BBT.Aether.AspNetCore.Telemetry;

Expand Down Expand Up @@ -113,6 +114,12 @@ public sealed class LoggingEnricherOptions

public sealed class AetherTracingOptions
{
/// <summary>
/// Controls whether only business spans or all diagnostic spans are produced.
/// Defaults to Business to keep production traces focused on service boundaries and business spans.
/// </summary>
public AetherTracingDetailLevel DetailLevel { get; set; } = AetherTracingDetailLevel.Business;

public bool EnableAspNetCore { get; set; } = true;
public bool EnableHttpClient { get; set; } = true;
public bool EnableEntityFrameworkCore { get; set; } = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text.RegularExpressions;
using BBT.Aether.AspNetCore.Telemetry;
using BBT.Aether.Telemetry;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -37,6 +39,7 @@ public static IServiceCollection AddAetherTelemetry(
}

section.Bind(opts);
AetherTracingRuntime.Configure(opts.Tracing.DetailLevel);

// Apply defaults and environment variables
var envName =
Expand Down Expand Up @@ -156,18 +159,23 @@ public static IServiceCollection AddAetherTelemetry(
{
tracing.AddHttpClientInstrumentation(o =>
{
o.FilterHttpRequestMessage = req =>
!IsExcluded(req.RequestUri?.ToString(), excludedPatterns);
o.FilterHttpRequestMessage = req => ShouldTraceHttpRequest(req, excludedPatterns);
o.EnrichWithHttpRequestMessage = EnrichHttpClientActivity;
});
}

if (opts.Tracing.EnableEntityFrameworkCore)
if (opts.Tracing.EnableEntityFrameworkCore && AetherTracingRuntime.IsVerbose)
{
tracing.AddEntityFrameworkCoreInstrumentation();
}

tracing.AddSource("BBT.Aether.Aspects");
tracing.AddSource("BBT.Aether.Infrastructure");

if (!AetherTracingRuntime.IsVerbose)
{
tracing.AddProcessor(new BusinessSpanFilterProcessor());
}

// Custom sources
foreach (var src in opts.Tracing.AdditionalSources)
Expand Down Expand Up @@ -285,6 +293,67 @@ private static bool IsExcluded(string? value, List<Regex> patterns)
return false;
}

private static bool ShouldTraceHttpRequest(HttpRequestMessage request, List<Regex> excludedPatterns)
{
if (IsExcluded(request.RequestUri?.ToString(), excludedPatterns))
{
return false;
}

return AetherTracingRuntime.IsVerbose || !IsDaprDiagnosticRequest(request.RequestUri);
}

private static bool IsDaprDiagnosticRequest(Uri? uri)
{
var path = uri?.AbsolutePath;
if (string.IsNullOrEmpty(path))
{
return false;
}

if (path.StartsWith("/dapr.proto.runtime.v1.Dapr/", StringComparison.OrdinalIgnoreCase))
{
return path.EndsWith("/GetState", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/GetBulkState", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/SaveState", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/DeleteState", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/ExecuteStateTransaction", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/GetSecret", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/GetBulkSecret", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/GetConfiguration", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/SubscribeConfiguration", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/TryLockAlpha1", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith("/UnlockAlpha1", StringComparison.OrdinalIgnoreCase);
}

return path.StartsWith("/v1.0/state/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/v1.0-alpha1/state/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/v1.0-alpha1/lock/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/v1.0/secrets/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/v1.0/configuration/", StringComparison.OrdinalIgnoreCase);
}

private static void EnrichHttpClientActivity(Activity activity, HttpRequestMessage request)
{
var segments = request.RequestUri?.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments is not { Length: >= 5 }
|| !string.Equals(segments[0], "v1.0", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "invoke", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[3], "method", StringComparison.OrdinalIgnoreCase))
{
return;
}

var appId = Uri.UnescapeDataString(segments[2]);
var method = Uri.UnescapeDataString(string.Join('/', segments.Skip(4)));

activity.DisplayName = $"Dapr invoke {appId}";
activity.SetTag("rpc.system", "dapr");
activity.SetTag("rpc.service", appId);
activity.SetTag("rpc.method", method);
activity.SetTag("dapr.app_id", appId);
}

private static string GetRoutePatternSafe(HttpContext httpContext)
{
try
Expand All @@ -302,4 +371,4 @@ public static string GetDeploymentId(AetherTelemetryOptions options, string envi
{
return $"{environment}-{options.ServiceName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N")[..8]}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using System.Threading;

namespace BBT.Aether.Telemetry;

/// <summary>
/// Controls the amount of tracing detail produced by Aether instrumentation.
/// </summary>
public enum AetherTracingDetailLevel
{
/// <summary>
/// Keeps service boundaries and business spans while suppressing diagnostic detail.
/// </summary>
Business = 0,

/// <summary>
/// Keeps both business and diagnostic spans.
/// </summary>
Verbose = 1
}

/// <summary>
/// Provides the process-wide tracing detail level used by static instrumentation and aspects.
/// </summary>
public static class AetherTracingRuntime
{
private static int _detailLevel = (int)AetherTracingDetailLevel.Business;

/// <summary>
/// Gets the active tracing detail level.
/// </summary>
public static AetherTracingDetailLevel DetailLevel =>
(AetherTracingDetailLevel)Volatile.Read(ref _detailLevel);

/// <summary>
/// Gets whether diagnostic spans are enabled.
/// </summary>
public static bool IsVerbose => DetailLevel == AetherTracingDetailLevel.Verbose;

/// <summary>
/// Configures the process-wide tracing detail level.
/// </summary>
public static void Configure(AetherTracingDetailLevel detailLevel)
{
if (!Enum.IsDefined(detailLevel))
{
throw new ArgumentOutOfRangeException(nameof(detailLevel), detailLevel, "Unsupported tracing detail level.");
}

Volatile.Write(ref _detailLevel, (int)detailLevel);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public override Task RefreshAsync(string key, CancellationToken cancellationToke

private Activity? StartCacheActivity(string operationName, string key)
{
var activity = InfrastructureActivitySource.Source.StartActivity(
var activity = InfrastructureActivitySource.StartDiagnosticActivity(
operationName,
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public async override Task RefreshAsync(string key, CancellationToken cancellati

private static Activity? StartCacheActivity(string operationName, string key)
{
var activity = InfrastructureActivitySource.Source.StartActivity(
var activity = InfrastructureActivitySource.StartDiagnosticActivity(
operationName,
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public async Task ReleaseAsync(CancellationToken cancellationToken = default)
if (Interlocked.Exchange(ref _disposed, 1) == 1)
return;

using var activity = InfrastructureActivitySource.Source.StartActivity(
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"DistributedLock.Release",
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public class DaprDistributedLockService(
[Obsolete("Use IDistributedLockHandle.ReleaseAsync() or dispose the handle returned by TryAcquireLockAsync. This method uses a static owner and cannot reliably release locks acquired concurrently.")]
public async Task<bool> ReleaseLockAsync(string resourceId, CancellationToken cancellationToken = default)
{
using var activity = InfrastructureActivitySource.Source.StartActivity(
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"DistributedLock.Release",
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down Expand Up @@ -151,7 +151,7 @@ private static string GenerateUniqueOwner()

private Activity? StartLockActivity(string operationName, string resourceId, int expiryInSeconds)
{
var activity = InfrastructureActivitySource.Source.StartActivity(
var activity = InfrastructureActivitySource.StartDiagnosticActivity(
operationName,
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public async Task<bool> ExtendAsync(int leaseSeconds, CancellationToken cancella
if (Volatile.Read(ref _disposed) == 1)
return false;

using var activity = InfrastructureActivitySource.Source.StartActivity(
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"DistributedLock.Extend",
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down Expand Up @@ -96,7 +96,7 @@ public async Task ReleaseAsync(CancellationToken cancellationToken = default)
if (Interlocked.Exchange(ref _disposed, 1) == 1)
return;

using var activity = InfrastructureActivitySource.Source.StartActivity(
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"DistributedLock.Release",
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class RedisDistributedLockService(
[Obsolete("Use IDistributedLockHandle.ReleaseAsync() or dispose the handle returned by TryAcquireLockAsync. This method uses a static owner and cannot reliably release locks acquired concurrently.")]
public async Task<bool> ReleaseLockAsync(string resourceId, CancellationToken cancellationToken = default)
{
using var activity = InfrastructureActivitySource.Source.StartActivity(
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"DistributedLock.Release",
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down Expand Up @@ -190,7 +190,7 @@ private static string GenerateUniqueOwner()

private static Activity? StartLockActivity(string operationName, string resourceId, int expiryInSeconds)
{
var activity = InfrastructureActivitySource.Source.StartActivity(
var activity = InfrastructureActivitySource.StartDiagnosticActivity(
operationName,
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,17 @@ public static class InfrastructureActivitySource
/// The shared ActivitySource instance for creating activities (spans) in Aether infrastructure.
/// </summary>
public static readonly ActivitySource Source = new(SourceName, Version);

/// <summary>
/// Starts an infrastructure diagnostic span only when the global tracing profile is Verbose.
/// </summary>
public static Activity? StartDiagnosticActivity(
string operationName,
ActivityKind kind,
ActivityContext parentContext = default)
{
return AetherTracingRuntime.IsVerbose
? Source.StartActivity(operationName, kind, parentContext)
: null;
}
}
Loading
Loading