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;
+ }
+ }
+}