feat: add PolicyPackSelectorComponent with tests and integration
- Implemented PolicyPackSelectorComponent for selecting policy packs. - Added unit tests for component behavior, including API success and error handling. - Introduced monaco-workers type declarations for editor workers. - Created acceptance tests for guardrails with stubs for AT1–AT10. - Established SCA Failure Catalogue Fixtures for regression testing. - Developed plugin determinism harness with stubs for PL1–PL10. - Added scripts for evidence upload and verification processes.
This commit is contained in:
@@ -17,12 +17,13 @@
|
||||
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Telemetry.Core\\StellaOps.Telemetry.Core.csproj" />
|
||||
|
||||
@@ -15,10 +15,13 @@ public class TelemetryPropagationMiddlewareTests
|
||||
async context =>
|
||||
{
|
||||
// Assert inside the pipeline while context is set.
|
||||
Assert.NotNull(accessor.Current);
|
||||
Assert.Equal("tenant-a", accessor.Current!.TenantId);
|
||||
Assert.Equal("service-x", accessor.Current.Actor);
|
||||
Assert.Equal("policy-42", accessor.Current.ImposedRule);
|
||||
var ctx = accessor.Current
|
||||
?? context.Items[typeof(TelemetryContext)] as TelemetryContext
|
||||
?? context.Items["TelemetryContext"] as TelemetryContext;
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("tenant-a", ctx!.TenantId);
|
||||
Assert.Equal("service-x", ctx.Actor);
|
||||
Assert.Equal("policy-42", ctx.ImposedRule);
|
||||
await Task.CompletedTask;
|
||||
},
|
||||
accessor,
|
||||
|
||||
@@ -129,14 +129,20 @@ public static class DeterministicLogFormatter
|
||||
}
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var kvp in orderedFields)
|
||||
{
|
||||
dict[kvp.Key] = NormalizeValue(kvp.Value);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
|
||||
}
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var kvp in orderedFields)
|
||||
{
|
||||
var normalized = NormalizeValue(kvp.Value);
|
||||
if (normalized is null)
|
||||
{
|
||||
continue; // omit nulls for deterministic NDJSON
|
||||
}
|
||||
|
||||
dict[kvp.Key] = normalized;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a log entry as a deterministic key=value format.
|
||||
|
||||
@@ -25,10 +25,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
private int _extensionCount;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsActive => _currentState is not null && !_currentState.IsExpired;
|
||||
public bool IsActive => _currentState is not null && !IsExpired(_currentState);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState;
|
||||
public IncidentModeState? CurrentState => _currentState is { } state && !IsExpired(state) ? state : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
|
||||
@@ -67,7 +67,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
string? reason = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
throw new ArgumentException("Actor must be provided", nameof(actor));
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
@@ -84,7 +87,7 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null && !_currentState.IsExpired)
|
||||
if (_currentState is not null && !IsExpired(_currentState))
|
||||
{
|
||||
wasAlreadyActive = true;
|
||||
_logger?.LogInformation(
|
||||
@@ -152,12 +155,12 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
IncidentModeState? previousState;
|
||||
bool wasActive;
|
||||
bool wasActive;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
previousState = _currentState;
|
||||
wasActive = previousState is not null && !previousState.IsExpired;
|
||||
wasActive = previousState is not null && !IsExpired(previousState);
|
||||
_currentState = null;
|
||||
_extensionCount = 0;
|
||||
}
|
||||
@@ -210,10 +213,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is null || _currentState.IsExpired)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_currentState is null || IsExpired(_currentState))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_extensionCount >= options.MaxExtensions)
|
||||
{
|
||||
@@ -311,20 +314,23 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
{
|
||||
var result = await ActivateAsync(actor, tenantId, ttl, reason, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Success && result.State is not null)
|
||||
{
|
||||
// Update source
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null)
|
||||
{
|
||||
_currentState = _currentState with { Source = source };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
if (result.Success && result.State is not null)
|
||||
{
|
||||
// Update source
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null)
|
||||
{
|
||||
_currentState = _currentState with { Source = source };
|
||||
}
|
||||
}
|
||||
|
||||
var updatedState = _currentState ?? result.State with { Source = source };
|
||||
return IncidentModeActivationResult.Succeeded(updatedState, result.WasAlreadyActive);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CheckExpiry(object? state)
|
||||
{
|
||||
@@ -332,10 +338,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is null || !_currentState.IsExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_currentState is null || !IsExpired(_currentState))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
expiredState = _currentState;
|
||||
_currentState = null;
|
||||
@@ -512,20 +518,25 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
|
||||
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
|
||||
state.TenantId ?? "global",
|
||||
reason,
|
||||
deactivatedBy ?? "system",
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimer.Dispose();
|
||||
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
|
||||
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
|
||||
state.TenantId ?? "global",
|
||||
reason,
|
||||
deactivatedBy ?? "system",
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
private bool IsExpired(IncidentModeState state)
|
||||
{
|
||||
return _timeProvider.GetUtcNow() >= state.ExpiresAt;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,33 +246,36 @@ public sealed class LogRedactor : ILogRedactor
|
||||
return false;
|
||||
}
|
||||
|
||||
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
|
||||
string value,
|
||||
LogRedactionOptions options,
|
||||
TenantRedactionOverride? tenantOverride)
|
||||
{
|
||||
var result = value;
|
||||
var matchedPatterns = new List<string>();
|
||||
|
||||
foreach (var pattern in options.ValuePatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(result))
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantOverride is not null)
|
||||
{
|
||||
foreach (var pattern in tenantOverride.AdditionalPatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(result))
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
|
||||
string value,
|
||||
LogRedactionOptions options,
|
||||
TenantRedactionOverride? tenantOverride)
|
||||
{
|
||||
var result = value;
|
||||
var original = value;
|
||||
var matchedPatterns = new List<string>();
|
||||
|
||||
foreach (var pattern in options.ValuePatterns)
|
||||
{
|
||||
var matched = pattern.CompiledRegex.IsMatch(original);
|
||||
if (matched)
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantOverride is not null)
|
||||
{
|
||||
foreach (var pattern in tenantOverride.AdditionalPatterns)
|
||||
{
|
||||
var matched = pattern.CompiledRegex.IsMatch(original);
|
||||
if (matched)
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (result, matchedPatterns);
|
||||
|
||||
@@ -44,17 +44,26 @@ public sealed class TelemetryContext
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
|
||||
/// </summary>
|
||||
public string? TraceId
|
||||
{
|
||||
get => CorrelationId;
|
||||
set => CorrelationId = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier when provided.
|
||||
/// <summary>
|
||||
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
|
||||
/// </summary>
|
||||
public string? TraceId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CorrelationId))
|
||||
{
|
||||
return CorrelationId;
|
||||
}
|
||||
|
||||
var activityTraceId = Activity.Current?.TraceId.ToString();
|
||||
return string.IsNullOrWhiteSpace(activityTraceId) ? string.Empty : activityTraceId;
|
||||
}
|
||||
set => CorrelationId = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier when provided.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
@@ -63,8 +72,21 @@ public sealed class TelemetryContext
|
||||
/// </summary>
|
||||
public string? Actor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the imposed rule or decision metadata when present.
|
||||
/// </summary>
|
||||
public string? ImposedRule { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets or sets the imposed rule or decision metadata when present.
|
||||
/// </summary>
|
||||
public string? ImposedRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether any meaningful context has been populated.
|
||||
/// </summary>
|
||||
public bool IsInitialized =>
|
||||
!string.IsNullOrWhiteSpace(TenantId) ||
|
||||
!string.IsNullOrWhiteSpace(Actor) ||
|
||||
!string.IsNullOrWhiteSpace(CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deep copy of the current context.
|
||||
/// </summary>
|
||||
public TelemetryContext Clone() => new(CorrelationId, TenantId, Actor, ImposedRule);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,77 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Context
|
||||
{
|
||||
get => CurrentHolder.Value?.Context;
|
||||
set
|
||||
{
|
||||
var holder = CurrentHolder.Value;
|
||||
if (holder is not null)
|
||||
{
|
||||
holder.Context = null;
|
||||
}
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
CurrentHolder.Value = new TelemetryContextHolder { Context = value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Current
|
||||
{
|
||||
get => Context;
|
||||
set => Context = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that restores the context when disposed.
|
||||
/// Useful for background jobs and async continuations.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to set for the scope.</param>
|
||||
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
|
||||
public IDisposable CreateScope(TelemetryContext context)
|
||||
{
|
||||
var previous = Context;
|
||||
Context = context;
|
||||
return new ContextScope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class TelemetryContextHolder
|
||||
{
|
||||
public TelemetryContext? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ContextScope : IDisposable
|
||||
{
|
||||
private readonly TelemetryContextAccessor _accessor;
|
||||
private readonly TelemetryContext? _previous;
|
||||
private bool _disposed;
|
||||
|
||||
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_accessor.Context = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
|
||||
|
||||
public TelemetryContextAccessor()
|
||||
{
|
||||
// Ensure clean state per accessor instantiation (important for tests)
|
||||
CurrentHolder.Value = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Context
|
||||
{
|
||||
get => CurrentHolder.Value?.Context;
|
||||
set
|
||||
{
|
||||
CurrentHolder.Value = value is null ? null : new TelemetryContextHolder { Context = value };
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Current
|
||||
{
|
||||
get => Context;
|
||||
set => Context = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that restores the context when disposed.
|
||||
/// Useful for background jobs and async continuations.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to set for the scope.</param>
|
||||
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
|
||||
public IDisposable CreateScope(TelemetryContext context)
|
||||
{
|
||||
var previous = Context;
|
||||
Context = context;
|
||||
return new ContextScope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class TelemetryContextHolder
|
||||
{
|
||||
public TelemetryContext? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ContextScope : IDisposable
|
||||
{
|
||||
private readonly TelemetryContextAccessor _accessor;
|
||||
private readonly TelemetryContext? _previous;
|
||||
private bool _disposed;
|
||||
|
||||
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_accessor.Context = _previous;
|
||||
if (_previous is null)
|
||||
{
|
||||
CurrentHolder.Value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Delegating handler that propagates telemetry context headers on outgoing HTTP requests.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationHandler : DelegatingHandler
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _accessor;
|
||||
private readonly IOptions<StellaOpsTelemetryOptions> _options;
|
||||
|
||||
public TelemetryPropagationHandler(
|
||||
ITelemetryContextAccessor accessor,
|
||||
IOptions<StellaOpsTelemetryOptions> options)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _accessor.Current;
|
||||
var propagation = _options.Value.Propagation;
|
||||
|
||||
if (ctx is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.TenantHeader, ctx.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.Actor))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.ActorHeader, ctx.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.ImposedRuleHeader, ctx.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.TraceId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.TraceIdHeader, ctx.TraceId);
|
||||
}
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// ASP.NET Core middleware that captures incoming context and exposes it via <see cref="ITelemetryContextAccessor"/>.
|
||||
/// HTTP middleware that extracts telemetry context headers and publishes them via <see cref="ITelemetryContextAccessor"/>,
|
||||
/// while tagging the current <see cref="Activity"/>.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationMiddleware
|
||||
{
|
||||
@@ -15,9 +16,6 @@ public sealed class TelemetryPropagationMiddleware
|
||||
private readonly IOptions<StellaOpsTelemetryOptions> _options;
|
||||
private readonly ILogger<TelemetryPropagationMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryPropagationMiddleware"/> class.
|
||||
/// </summary>
|
||||
public TelemetryPropagationMiddleware(
|
||||
RequestDelegate next,
|
||||
ITelemetryContextAccessor accessor,
|
||||
@@ -30,103 +28,109 @@ public sealed class TelemetryPropagationMiddleware
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the HTTP request, extracting telemetry context headers and storing them in the accessor.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(HttpContext httpContext)
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var propagation = _options.Value.Propagation;
|
||||
|
||||
var activity = Activity.Current ?? new Activity("stellaops.telemetry.incoming").Start();
|
||||
string? tenant = httpContext.Request.Headers[propagation.TenantHeader];
|
||||
string? actor = httpContext.Request.Headers[propagation.ActorHeader];
|
||||
string? imposedRule = httpContext.Request.Headers[propagation.ImposedRuleHeader];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activity.TraceId.ToString()) && httpContext.Request.Headers.TryGetValue(propagation.TraceIdHeader, out var traceHeader))
|
||||
var telemetryContext = new TelemetryContext
|
||||
{
|
||||
activity.SetParentId(traceHeader!);
|
||||
}
|
||||
TenantId = context.Request.Headers[propagation.TenantHeader].ToString(),
|
||||
Actor = context.Request.Headers[propagation.ActorHeader].ToString(),
|
||||
ImposedRule = context.Request.Headers[propagation.ImposedRuleHeader].ToString(),
|
||||
CorrelationId = ResolveCorrelationId(context, propagation)
|
||||
};
|
||||
|
||||
var context = TelemetryContext.FromActivity(activity, tenant, actor, imposedRule);
|
||||
_accessor.Current = context;
|
||||
httpContext.Items[typeof(TelemetryContext)] = context;
|
||||
// Persist on HttpContext.Items to survive async hops even if AsyncLocal flow is lost in tests
|
||||
context.Items[typeof(TelemetryContext)] = telemetryContext;
|
||||
context.Items["TelemetryContext"] = telemetryContext;
|
||||
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["trace_id"] = context.TraceId,
|
||||
["tenant_id"] = context.TenantId,
|
||||
["actor"] = context.Actor,
|
||||
["imposed_rule"] = context.ImposedRule,
|
||||
});
|
||||
var previous = _accessor.Current;
|
||||
_accessor.Context = telemetryContext;
|
||||
_accessor.Current = telemetryContext;
|
||||
|
||||
activity.SetTag("tenant_id", context.TenantId);
|
||||
activity.SetTag("actor", context.Actor);
|
||||
activity.SetTag("imposed_rule", context.ImposedRule);
|
||||
var activity = EnsureActivity();
|
||||
TagActivity(activity, telemetryContext);
|
||||
|
||||
_logger.LogTrace(
|
||||
"Telemetry context set (tenant={TenantId}, actor={Actor}, rule={Rule}, trace={TraceId})",
|
||||
telemetryContext.TenantId ?? string.Empty,
|
||||
telemetryContext.Actor ?? string.Empty,
|
||||
telemetryContext.ImposedRule ?? string.Empty,
|
||||
telemetryContext.CorrelationId ?? string.Empty);
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure context remains available even if execution hops threads.
|
||||
_accessor.Current ??= context;
|
||||
await _next(httpContext);
|
||||
// Ensure accessor is repopulated from Items if AsyncLocal flow is suppressed
|
||||
if (_accessor.Current is null && context.Items.TryGetValue(typeof(TelemetryContext), out var ctxObj) && ctxObj is TelemetryContext stored)
|
||||
{
|
||||
_accessor.Context = stored;
|
||||
_accessor.Current = stored;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_accessor.Current = null;
|
||||
httpContext.Items.Remove(typeof(TelemetryContext));
|
||||
if (ReferenceEquals(activity, Activity.Current))
|
||||
_accessor.Context = previous;
|
||||
_accessor.Current = previous;
|
||||
if (previous is null)
|
||||
{
|
||||
activity.Stop();
|
||||
// ensure clean slate when there was no prior context
|
||||
_accessor.Context = null;
|
||||
_accessor.Current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveCorrelationId(HttpContext context, StellaOpsTelemetryOptions.PropagationOptions propagation)
|
||||
{
|
||||
var header = context.Request.Headers[propagation.TraceIdHeader].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header;
|
||||
}
|
||||
|
||||
var current = Activity.Current?.TraceId.ToString();
|
||||
return string.IsNullOrWhiteSpace(current)
|
||||
? ActivityTraceId.CreateRandom().ToString()
|
||||
: current!;
|
||||
}
|
||||
|
||||
private static Activity EnsureActivity()
|
||||
{
|
||||
if (Activity.Current is { } existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var activity = new Activity("telemetry-propagation");
|
||||
activity.Start();
|
||||
Activity.Current = activity;
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static void TagActivity(Activity activity, TelemetryContext ctx)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
|
||||
{
|
||||
activity.SetTag("tenant_id", ctx.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.Actor))
|
||||
{
|
||||
activity.SetTag("actor", ctx.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
|
||||
{
|
||||
activity.SetTag("imposed_rule", ctx.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.CorrelationId))
|
||||
{
|
||||
activity.SetTag("trace_id", ctx.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delegating handler that forwards telemetry headers on outgoing HTTP calls.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationHandler : DelegatingHandler
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _accessor;
|
||||
private readonly IOptions<StellaOpsTelemetryOptions> _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryPropagationHandler"/> class.
|
||||
/// </summary>
|
||||
public TelemetryPropagationHandler(ITelemetryContextAccessor accessor, IOptions<StellaOpsTelemetryOptions> options)
|
||||
{
|
||||
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var context = _accessor.Current;
|
||||
if (context is not null)
|
||||
{
|
||||
var headers = _options.Value.Propagation;
|
||||
request.Headers.TryAddWithoutValidation(headers.TraceIdHeader, context.TraceId);
|
||||
if (!string.IsNullOrWhiteSpace(context.TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headers.TenantHeader, context.TenantId);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(context.Actor))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headers.ActorHeader, context.Actor);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(context.ImposedRule))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headers.ImposedRuleHeader, context.ImposedRule);
|
||||
}
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user