diff --git a/framework/docs/aspects/README.md b/framework/docs/aspects/README.md index 5369ba6..851bb6b 100644 --- a/framework/docs/aspects/README.md +++ b/framework/docs/aspects/README.md @@ -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() { } @@ -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:** @@ -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. @@ -260,4 +268,3 @@ public class OrderAppService - [Unit of Work](../unit-of-work/README.md) - Detailed UoW documentation - [Telemetry](../telemetry/README.md) - OpenTelemetry setup - diff --git a/framework/docs/telemetry/README.md b/framework/docs/telemetry/README.md index bf9df48..b7fe778 100644 --- a/framework/docs/telemetry/README.md +++ b/framework/docs/telemetry/README.md @@ -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"], @@ -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`. @@ -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 @@ -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 - diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/BusinessSpanFilterProcessor.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/BusinessSpanFilterProcessor.cs new file mode 100644 index 0000000..ebba835 --- /dev/null +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/BusinessSpanFilterProcessor.cs @@ -0,0 +1,27 @@ +using System; +using System.Diagnostics; +using OpenTelemetry; + +namespace BBT.Aether.AspNetCore.Telemetry; + +/// +/// Removes pipeline-detail spans from the export path when the Business tracing profile is active. +/// +/// +/// 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. +/// +internal sealed class BusinessSpanFilterProcessor : BaseProcessor +{ + public override void OnEnd(Activity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + if (activity.DisplayName.StartsWith("[", StringComparison.Ordinal)) + { + activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + } + } +} diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs index 77554de..786e4ad 100644 --- a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using BBT.Aether.Telemetry; namespace BBT.Aether.AspNetCore.Telemetry; @@ -113,6 +114,12 @@ public sealed class LoggingEnricherOptions public sealed class AetherTracingOptions { + /// + /// 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. + /// + 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; diff --git a/framework/src/BBT.Aether.AspNetCore/Microsoft/Extensions/DependencyInjection/AetherTelemetryServiceCollectionExtensions.cs b/framework/src/BBT.Aether.AspNetCore/Microsoft/Extensions/DependencyInjection/AetherTelemetryServiceCollectionExtensions.cs index d47684b..1d9d970 100644 --- a/framework/src/BBT.Aether.AspNetCore/Microsoft/Extensions/DependencyInjection/AetherTelemetryServiceCollectionExtensions.cs +++ b/framework/src/BBT.Aether.AspNetCore/Microsoft/Extensions/DependencyInjection/AetherTelemetryServiceCollectionExtensions.cs @@ -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; @@ -37,6 +39,7 @@ public static IServiceCollection AddAetherTelemetry( } section.Bind(opts); + AetherTracingRuntime.Configure(opts.Tracing.DetailLevel); // Apply defaults and environment variables var envName = @@ -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) @@ -285,6 +293,67 @@ private static bool IsExcluded(string? value, List patterns) return false; } + private static bool ShouldTraceHttpRequest(HttpRequestMessage request, List 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 @@ -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]}"; } -} \ No newline at end of file +} diff --git a/framework/src/BBT.Aether.Core/BBT/Aether/Telemetry/AetherTracingProfile.cs b/framework/src/BBT.Aether.Core/BBT/Aether/Telemetry/AetherTracingProfile.cs new file mode 100644 index 0000000..1a6885a --- /dev/null +++ b/framework/src/BBT.Aether.Core/BBT/Aether/Telemetry/AetherTracingProfile.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; + +namespace BBT.Aether.Telemetry; + +/// +/// Controls the amount of tracing detail produced by Aether instrumentation. +/// +public enum AetherTracingDetailLevel +{ + /// + /// Keeps service boundaries and business spans while suppressing diagnostic detail. + /// + Business = 0, + + /// + /// Keeps both business and diagnostic spans. + /// + Verbose = 1 +} + +/// +/// Provides the process-wide tracing detail level used by static instrumentation and aspects. +/// +public static class AetherTracingRuntime +{ + private static int _detailLevel = (int)AetherTracingDetailLevel.Business; + + /// + /// Gets the active tracing detail level. + /// + public static AetherTracingDetailLevel DetailLevel => + (AetherTracingDetailLevel)Volatile.Read(ref _detailLevel); + + /// + /// Gets whether diagnostic spans are enabled. + /// + public static bool IsVerbose => DetailLevel == AetherTracingDetailLevel.Verbose; + + /// + /// Configures the process-wide tracing detail level. + /// + 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); + } +} diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Dapr/DaprDistributedCacheService.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Dapr/DaprDistributedCacheService.cs index 3026955..f029d85 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Dapr/DaprDistributedCacheService.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Dapr/DaprDistributedCacheService.cs @@ -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); diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Redis/RedisDistributedCacheService.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Redis/RedisDistributedCacheService.cs index dc7960f..6c283cb 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Redis/RedisDistributedCacheService.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedCache/Redis/RedisDistributedCacheService.cs @@ -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); diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockHandle.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockHandle.cs index 0ca7c49..0013804 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockHandle.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockHandle.cs @@ -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); diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockService.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockService.cs index 8b596bf..f391773 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockService.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Dapr/DaprDistributedLockService.cs @@ -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 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); @@ -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); diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockHandle.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockHandle.cs index 43c8092..f65fc05 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockHandle.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockHandle.cs @@ -46,7 +46,7 @@ public async Task 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); @@ -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); diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockService.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockService.cs index 209846c..5769ff0 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockService.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/DistributedLock/Redis/RedisDistributedLockService.cs @@ -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 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); @@ -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); diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Telemetry/InfrastructureActivitySource.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Telemetry/InfrastructureActivitySource.cs index 951af29..212e2bd 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Telemetry/InfrastructureActivitySource.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Telemetry/InfrastructureActivitySource.cs @@ -22,4 +22,17 @@ public static class InfrastructureActivitySource /// The shared ActivitySource instance for creating activities (spans) in Aether infrastructure. /// public static readonly ActivitySource Source = new(SourceName, Version); + + /// + /// Starts an infrastructure diagnostic span only when the global tracing profile is Verbose. + /// + public static Activity? StartDiagnosticActivity( + string operationName, + ActivityKind kind, + ActivityContext parentContext = default) + { + return AetherTracingRuntime.IsVerbose + ? Source.StartActivity(operationName, kind, parentContext) + : null; + } } diff --git a/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Tracing/TraceDetailLevelTests.cs b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Tracing/TraceDetailLevelTests.cs new file mode 100644 index 0000000..5904032 --- /dev/null +++ b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Tracing/TraceDetailLevelTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using BBT.Aether.Aspects; +using BBT.Aether.Telemetry; +using Xunit; + +namespace BBT.Aether.Tracing; + +public sealed class TraceDetailLevelTests +{ + [Fact] + public void Runtime_uses_global_detail_level() + { + Assert.Equal(AetherTracingDetailLevel.Business, AetherTracingRuntime.DetailLevel); + + try + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + + Assert.False(AetherTracingRuntime.IsVerbose); + + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Verbose); + + Assert.True(AetherTracingRuntime.IsVerbose); + } + finally + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + } + } + + [Fact] + public async Task Trace_annotation_creates_business_span_in_business_profile() + { + var startedActivities = new List(); + using var listener = CreateListener(startedActivities); + + try + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + + var probe = new TraceProbe(); + await probe.ExecuteAsync(); + + Assert.True(probe.Executed); + var activity = Assert.Single(startedActivities); + Assert.Equal("TraceProbe.ExecuteAsync", activity.OperationName); + } + finally + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + } + } + + [Fact] + public async Task Trace_annotation_creates_span_in_verbose_profile() + { + var startedActivities = new List(); + using var listener = CreateListener(startedActivities); + + try + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Verbose); + + await new TraceProbe().ExecuteAsync(); + + var activity = Assert.Single(startedActivities); + Assert.Equal("TraceProbe.ExecuteAsync", activity.OperationName); + } + finally + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + } + } + + [Fact] + public void Infrastructure_diagnostics_follow_global_detail_level() + { + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == InfrastructureActivitySource.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(listener); + + try + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + using var businessActivity = InfrastructureActivitySource.StartDiagnosticActivity( + "DistributedCache.Get", + ActivityKind.Client); + + Assert.Null(businessActivity); + + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Verbose); + using var verboseActivity = InfrastructureActivitySource.StartDiagnosticActivity( + "DistributedCache.Get", + ActivityKind.Client); + + Assert.NotNull(verboseActivity); + Assert.Equal("DistributedCache.Get", verboseActivity.OperationName); + } + finally + { + AetherTracingRuntime.Configure(AetherTracingDetailLevel.Business); + } + } + + private static ActivityListener CreateListener(List startedActivities) + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == AetherActivitySource.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => + { + if (activity.OperationName == "TraceProbe.ExecuteAsync") + { + startedActivities.Add(activity); + } + } + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + + private sealed class TraceProbe + { + public bool Executed { get; private set; } + + [Trace] + public Task ExecuteAsync() + { + Executed = true; + return Task.CompletedTask; + } + } +} diff --git a/framework/test/BBT.Aether.Postgres.Tests/BusinessSpanFilterTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BusinessSpanFilterTests.cs new file mode 100644 index 0000000..f6b3285 --- /dev/null +++ b/framework/test/BBT.Aether.Postgres.Tests/BusinessSpanFilterTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Diagnostics; +using BBT.Aether.AspNetCore.Telemetry; +using BBT.Aether.Telemetry; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Trace; +using Xunit; + +namespace BBT.Aether.Postgres.Tests; + +public sealed class BusinessSpanFilterTests +{ + private const string ActivitySourceName = "BBT.Aether.Tests.BusinessSpanFilter"; + + [Fact] + public void Tracing_options_default_to_business() + { + Assert.Equal(AetherTracingDetailLevel.Business, new AetherTracingOptions().DetailLevel); + } + + [Fact] + public void Business_profile_filters_only_bracket_prefixed_display_names() + { + var exportedNames = ExportTwoActivities(AetherTracingDetailLevel.Business); + + Assert.Equal(["transition/start"], exportedNames); + } + + [Fact] + public void Verbose_profile_keeps_bracket_prefixed_display_names() + { + var exportedNames = ExportTwoActivities(AetherTracingDetailLevel.Verbose); + + Assert.Equal(["[20] CreateTransitionRecordStep", "transition/start"], exportedNames); + } + + private static List ExportTwoActivities(AetherTracingDetailLevel detailLevel) + { + var exportedNames = new List(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Telemetry:ServiceName"] = "business-span-filter-tests", + ["Telemetry:MetricsEnabled"] = "false", + ["Telemetry:LoggingEnabled"] = "false", + ["Telemetry:Tracing:DetailLevel"] = detailLevel.ToString(), + ["Telemetry:Tracing:EnableAspNetCore"] = "false", + ["Telemetry:Tracing:EnableHttpClient"] = "false", + ["Telemetry:Tracing:EnableEntityFrameworkCore"] = "false", + ["Telemetry:Tracing:EnableConsoleExporter"] = "false", + ["Telemetry:Tracing:EnableOtlpExporter"] = "false", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddAetherTelemetry( + configuration, + configure: builder => builder.ConfigureTracing((_, tracing) => + { + tracing.AddSource(ActivitySourceName); + tracing.AddProcessor( + new SimpleActivityExportProcessor(new CapturingActivityExporter(exportedNames))); + })); + + using var serviceProvider = services.BuildServiceProvider(); + using var tracerProvider = serviceProvider.GetRequiredService(); + using var source = new ActivitySource(ActivitySourceName); + + using (var pipelineStep = source.StartActivity("PipelineStep.ExecuteAsync")) + { + Assert.NotNull(pipelineStep); + pipelineStep.DisplayName = "[20] CreateTransitionRecordStep"; + } + + using (var transition = source.StartActivity("TransitionExecutor.ExecuteOneAsync")) + { + Assert.NotNull(transition); + transition.DisplayName = "transition/start"; + } + + return exportedNames; + } + + private sealed class CapturingActivityExporter(List exportedNames) : BaseExporter + { + public override ExportResult Export(in Batch batch) + { + foreach (var activity in batch) + { + exportedNames.Add(activity.DisplayName); + } + + return ExportResult.Success; + } + } +}