up
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Override repo defaults to keep telemetry tests self-contained -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<ConcelierTestingPath></ConcelierTestingPath>
|
||||
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project>
|
||||
<!-- Prevent global plugin/test copy targets from firing for telemetry tests -->
|
||||
<Target Name="DisablePluginCopyTargets" BeforeTargets="ConcelierCopyPluginArtifacts;AuthorityCopyPluginArtifacts;NotifyCopyPluginArtifacts;ScannerCopyBuildxPluginArtifacts;ScannerCopyOsAnalyzerPluginArtifacts;ScannerCopyLangAnalyzerPluginArtifacts">
|
||||
<PropertyGroup>
|
||||
<ConcelierPluginOutputRoot></ConcelierPluginOutputRoot>
|
||||
<AuthorityPluginOutputRoot></AuthorityPluginOutputRoot>
|
||||
<NotifyPluginOutputRoot></NotifyPluginOutputRoot>
|
||||
<ScannerBuildxPluginOutputRoot></ScannerBuildxPluginOutputRoot>
|
||||
<ScannerOsAnalyzerPluginOutputRoot></ScannerOsAnalyzerPluginOutputRoot>
|
||||
<ScannerLangAnalyzerPluginOutputRoot></ScannerLangAnalyzerPluginOutputRoot>
|
||||
<IsConcelierPlugin>false</IsConcelierPlugin>
|
||||
<IsAuthorityPlugin>false</IsAuthorityPlugin>
|
||||
<IsNotifyPlugin>false</IsNotifyPlugin>
|
||||
<IsScannerBuildxPlugin>false</IsScannerBuildxPlugin>
|
||||
<IsScannerOsAnalyzerPlugin>false</IsScannerOsAnalyzerPlugin>
|
||||
<IsScannerLangAnalyzerPlugin>false</IsScannerLangAnalyzerPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ConcelierPluginArtifacts Remove="@(ConcelierPluginArtifacts)" />
|
||||
<AuthorityPluginArtifacts Remove="@(AuthorityPluginArtifacts)" />
|
||||
<NotifyPluginArtifacts Remove="@(NotifyPluginArtifacts)" />
|
||||
<ScannerBuildxPluginArtifacts Remove="@(ScannerBuildxPluginArtifacts)" />
|
||||
<ScannerOsAnalyzerPluginArtifacts Remove="@(ScannerOsAnalyzerPluginArtifacts)" />
|
||||
<ScannerLangAnalyzerPluginArtifacts Remove="@(ScannerLangAnalyzerPluginArtifacts)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
public class MetricLabelGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void Coerce_Enforces_Cardinality_Limit()
|
||||
{
|
||||
var options = Options.Create(new StellaOpsTelemetryOptions
|
||||
{
|
||||
Labels = new StellaOpsTelemetryOptions.MetricLabelOptions
|
||||
{
|
||||
MaxDistinctValuesPerLabel = 2,
|
||||
MaxLabelLength = 8
|
||||
}
|
||||
});
|
||||
|
||||
var guard = new MetricLabelGuard(options);
|
||||
|
||||
var first = guard.Coerce("route", "/api/a");
|
||||
var second = guard.Coerce("route", "/api/b");
|
||||
var third = guard.Coerce("route", "/api/c");
|
||||
|
||||
Assert.Equal("/api/a", first);
|
||||
Assert.Equal("/api/b", second);
|
||||
Assert.Equal("other", third); // budget exceeded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordRequestDuration_Truncates_Long_Labels()
|
||||
{
|
||||
var options = Options.Create(new StellaOpsTelemetryOptions
|
||||
{
|
||||
Labels = new StellaOpsTelemetryOptions.MetricLabelOptions
|
||||
{
|
||||
MaxDistinctValuesPerLabel = 5,
|
||||
MaxLabelLength = 5
|
||||
}
|
||||
});
|
||||
|
||||
var guard = new MetricLabelGuard(options);
|
||||
using var meter = new Meter("test");
|
||||
var histogram = meter.CreateHistogram<double>("request.duration");
|
||||
|
||||
histogram.RecordRequestDuration(guard, 42, "verylongroute", "GET", "200", "ok");
|
||||
|
||||
// No exception means recording succeeded; label value should be truncated internally to 5 chars.
|
||||
Assert.Equal("veryl", guard.Coerce("route", "verylongroute"));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,18 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Opt out of Concelier test infra to avoid pulling large cross-module graph -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<!-- Prevent repo-wide test infra from pulling Concelier shared test packages -->
|
||||
<PackageReference Remove="Mongo2Go" />
|
||||
<PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
public class TelemetryPropagationHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handler_Forwards_Context_Headers()
|
||||
{
|
||||
var options = Options.Create(new StellaOpsTelemetryOptions());
|
||||
var accessor = new TelemetryContextAccessor
|
||||
{
|
||||
Current = new TelemetryContext(
|
||||
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
|
||||
"tenant-b",
|
||||
"actor-b",
|
||||
"rule-b")
|
||||
};
|
||||
|
||||
var terminal = new RecordingHandler();
|
||||
var handler = new TelemetryPropagationHandler(accessor, options)
|
||||
{
|
||||
InnerHandler = terminal
|
||||
};
|
||||
|
||||
var invoker = new HttpMessageInvoker(handler);
|
||||
await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://example.com"), CancellationToken.None);
|
||||
|
||||
Assert.Equal("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01", terminal.SeenHeaders[options.Value.Propagation.TraceIdHeader]);
|
||||
Assert.Equal("tenant-b", terminal.SeenHeaders[options.Value.Propagation.TenantHeader]);
|
||||
Assert.Equal("actor-b", terminal.SeenHeaders[options.Value.Propagation.ActorHeader]);
|
||||
Assert.Equal("rule-b", terminal.SeenHeaders[options.Value.Propagation.ImposedRuleHeader]);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public Dictionary<string, string?> SeenHeaders { get; } = new();
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
SeenHeaders[header.Key.ToLowerInvariant()] = header.Value.FirstOrDefault();
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
public class TelemetryPropagationMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Middleware_Populates_Accessor_And_Activity_Tags()
|
||||
{
|
||||
var options = Options.Create(new StellaOpsTelemetryOptions());
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var middleware = new TelemetryPropagationMiddleware(
|
||||
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);
|
||||
await Task.CompletedTask;
|
||||
},
|
||||
accessor,
|
||||
options,
|
||||
NullLogger<TelemetryPropagationMiddleware>.Instance);
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers[options.Value.Propagation.TenantHeader] = "tenant-a";
|
||||
httpContext.Request.Headers[options.Value.Propagation.ActorHeader] = "service-x";
|
||||
httpContext.Request.Headers[options.Value.Propagation.ImposedRuleHeader] = "policy-42";
|
||||
httpContext.Request.Headers[options.Value.Propagation.TraceIdHeader] = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
|
||||
|
||||
Assert.Null(accessor.Current);
|
||||
await middleware.InvokeAsync(httpContext);
|
||||
Assert.Null(accessor.Current); // cleared after invocation
|
||||
|
||||
Assert.NotNull(Activity.Current);
|
||||
Assert.Equal("tenant-a", Activity.Current!.GetTagItem("tenant_id"));
|
||||
Assert.Equal("service-x", Activity.Current.GetTagItem("actor"));
|
||||
Assert.Equal("policy-42", Activity.Current.GetTagItem("imposed_rule"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Guards metric label cardinality to keep exporters deterministic and affordable.
|
||||
/// </summary>
|
||||
public sealed class MetricLabelGuard
|
||||
{
|
||||
private readonly int _maxValuesPerLabel;
|
||||
private readonly int _maxLabelLength;
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _seen;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MetricLabelGuard"/> class.
|
||||
/// </summary>
|
||||
public MetricLabelGuard(IOptions<StellaOpsTelemetryOptions> options)
|
||||
{
|
||||
var labelOptions = options?.Value?.Labels ?? new StellaOpsTelemetryOptions.MetricLabelOptions();
|
||||
_maxValuesPerLabel = Math.Max(1, labelOptions.MaxDistinctValuesPerLabel);
|
||||
_maxLabelLength = Math.Max(1, labelOptions.MaxLabelLength);
|
||||
_seen = new ConcurrentDictionary<string, ConcurrentDictionary<string, byte>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a label value if within budget; otherwise falls back to a deterministic bucket label.
|
||||
/// </summary>
|
||||
public string Coerce(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
var sanitized = (value ?? string.Empty).Trim();
|
||||
if (sanitized.Length > _maxLabelLength)
|
||||
{
|
||||
sanitized = sanitized[.._maxLabelLength];
|
||||
}
|
||||
|
||||
var perKey = _seen.GetOrAdd(key, _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
|
||||
if (perKey.Count >= _maxValuesPerLabel && !perKey.ContainsKey(sanitized))
|
||||
{
|
||||
return "other";
|
||||
}
|
||||
|
||||
perKey.TryAdd(sanitized, 0);
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metric helpers aligned with StellaOps golden-signal defaults.
|
||||
/// </summary>
|
||||
public static class TelemetryMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a request duration histogram with cardinality-safe labels.
|
||||
/// </summary>
|
||||
public static void RecordRequestDuration(
|
||||
this Histogram<double> histogram,
|
||||
MetricLabelGuard guard,
|
||||
double durationMs,
|
||||
string route,
|
||||
string verb,
|
||||
string statusCode,
|
||||
string result)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("route", guard.Coerce("route", route)),
|
||||
new("verb", guard.Coerce("verb", verb)),
|
||||
new("status_code", guard.Coerce("status_code", statusCode)),
|
||||
new("result", guard.Coerce("result", result)),
|
||||
};
|
||||
|
||||
histogram.Record(durationMs, tags);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
|
||||
@@ -12,6 +12,16 @@ public sealed class StellaOpsTelemetryOptions
|
||||
/// </summary>
|
||||
public CollectorOptions Collector { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets propagation-specific settings used by middleware and handlers.
|
||||
/// </summary>
|
||||
public PropagationOptions Propagation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets metric label guard settings to prevent cardinality explosions.
|
||||
/// </summary>
|
||||
public MetricLabelOptions Labels { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Options describing how the OTLP collector exporter should be configured.
|
||||
/// </summary>
|
||||
@@ -63,6 +73,48 @@ public sealed class StellaOpsTelemetryOptions
|
||||
return Uri.TryCreate(Endpoint.Trim(), UriKind.Absolute, out endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling telemetry context propagation.
|
||||
/// </summary>
|
||||
public sealed class PropagationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the header name carrying the tenant identifier.
|
||||
/// </summary>
|
||||
public string TenantHeader { get; set; } = "x-stella-tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the header name carrying the actor (user/service) identifier.
|
||||
/// </summary>
|
||||
public string ActorHeader { get; set; } = "x-stella-actor";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the header name carrying imposed rule/decision metadata.
|
||||
/// </summary>
|
||||
public string ImposedRuleHeader { get; set; } = "x-stella-imposed-rule";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the header name carrying the trace identifier when no Activity is present.
|
||||
/// </summary>
|
||||
public string TraceIdHeader { get; set; } = "x-stella-traceid";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options used to constrain metric label cardinality.
|
||||
/// </summary>
|
||||
public sealed class MetricLabelOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of distinct values tracked per label key.
|
||||
/// </summary>
|
||||
public int MaxDistinctValuesPerLabel { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum length of any individual label value; longer values are trimmed.
|
||||
/// </summary>
|
||||
public int MaxLabelLength { get; set; } = 64;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the minimal propagation envelope used across HTTP/gRPC/jobs/CLI.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="TelemetryContext"/> using the current activity if present.
|
||||
/// </summary>
|
||||
public static TelemetryContext FromActivity(Activity? activity, string? tenantId = null, string? actor = null, string? imposedRule = null)
|
||||
{
|
||||
var traceId = activity?.TraceId.ToString() ?? activity?.RootId ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(traceId))
|
||||
{
|
||||
traceId = ActivityTraceId.CreateRandom().ToString();
|
||||
}
|
||||
|
||||
return new TelemetryContext(traceId, tenantId, actor, imposedRule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryContext"/> class.
|
||||
/// </summary>
|
||||
public TelemetryContext(string traceId, string? tenantId, string? actor, string? imposedRule)
|
||||
{
|
||||
TraceId = string.IsNullOrWhiteSpace(traceId) ? ActivityTraceId.CreateRandom().ToString() : traceId.Trim();
|
||||
TenantId = tenantId?.Trim();
|
||||
Actor = actor?.Trim();
|
||||
ImposedRule = imposedRule?.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the distributed trace identifier.
|
||||
/// </summary>
|
||||
public string TraceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier when provided.
|
||||
/// </summary>
|
||||
public string? TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor identifier (user or service principal).
|
||||
/// </summary>
|
||||
public string? Actor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the imposed rule or decision metadata when present.
|
||||
/// </summary>
|
||||
public string? ImposedRule { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
|
||||
{
|
||||
private readonly AsyncLocal<TelemetryContext?> _localContext = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public TelemetryContext? Current
|
||||
{
|
||||
get => _localContext.Value;
|
||||
set => _localContext.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accessor abstraction for telemetry context.
|
||||
/// </summary>
|
||||
public interface ITelemetryContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current context bound to the async flow.
|
||||
/// </summary>
|
||||
TelemetryContext? Current { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// ASP.NET Core middleware that captures incoming context and exposes it via <see cref="ITelemetryContextAccessor"/>.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ITelemetryContextAccessor _accessor;
|
||||
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,
|
||||
IOptions<StellaOpsTelemetryOptions> options,
|
||||
ILogger<TelemetryPropagationMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
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))
|
||||
{
|
||||
activity.SetParentId(traceHeader!);
|
||||
}
|
||||
|
||||
var context = TelemetryContext.FromActivity(activity, tenant, actor, imposedRule);
|
||||
_accessor.Current = context;
|
||||
httpContext.Items[typeof(TelemetryContext)] = context;
|
||||
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["trace_id"] = context.TraceId,
|
||||
["tenant_id"] = context.TenantId,
|
||||
["actor"] = context.Actor,
|
||||
["imposed_rule"] = context.ImposedRule,
|
||||
});
|
||||
|
||||
activity.SetTag("tenant_id", context.TenantId);
|
||||
activity.SetTag("actor", context.Actor);
|
||||
activity.SetTag("imposed_rule", context.ImposedRule);
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure context remains available even if execution hops threads.
|
||||
_accessor.Current ??= context;
|
||||
await _next(httpContext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_accessor.Current = null;
|
||||
httpContext.Items.Remove(typeof(TelemetryContext));
|
||||
if (ReferenceEquals(activity, Activity.Current))
|
||||
{
|
||||
activity.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
@@ -8,6 +9,7 @@ using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Logs;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
@@ -55,6 +57,9 @@ public static class TelemetryServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton(_ => new TelemetryServiceDescriptor(serviceName, serviceVersion));
|
||||
services.TryAddSingleton<TelemetryExporterGuard>();
|
||||
services.TryAddSingleton<ITelemetryContextAccessor, TelemetryContextAccessor>();
|
||||
services.TryAddSingleton<MetricLabelGuard>();
|
||||
services.AddTransient<TelemetryPropagationHandler>();
|
||||
|
||||
var builder = services.AddOpenTelemetry();
|
||||
builder.ConfigureResource(resource => resource.AddService(serviceName, serviceVersion: serviceVersion));
|
||||
@@ -77,21 +82,13 @@ public static class TelemetryServiceCollectionExtensions
|
||||
ConfigureCollectorExporter(sp, meterBuilder, TelemetrySignal.Metrics);
|
||||
});
|
||||
|
||||
services.Configure<OpenTelemetryLoggerOptions>((sp, options) =>
|
||||
{
|
||||
var configure = BuildExporterConfiguration(sp, TelemetrySignal.Logs);
|
||||
if (configure is not null)
|
||||
{
|
||||
options.AddOtlpExporter(configure);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void DefaultMetricsSetup(MeterProviderBuilder builder)
|
||||
{
|
||||
builder.AddRuntimeInstrumentation();
|
||||
builder.AddMeter("StellaOps.Telemetry");
|
||||
}
|
||||
|
||||
private static void DefaultTracingSetup(TracerProviderBuilder builder)
|
||||
@@ -171,4 +168,22 @@ public static class TelemetryServiceCollectionExtensions
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the telemetry propagation middleware to the ASP.NET Core pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseStellaOpsTelemetryContext(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
return app.UseMiddleware<TelemetryPropagationMiddleware>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the telemetry propagation handler to an HttpClient pipeline.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddTelemetryPropagation(this IHttpClientBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
return builder.AddHttpMessageHandler<TelemetryPropagationHandler>();
|
||||
}
|
||||
}
|
||||
|
||||
10
src/Telemetry/StellaOps.Telemetry.Core/telemetry-tests.slnf
Normal file
10
src/Telemetry/StellaOps.Telemetry.Core/telemetry-tests.slnf
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "../../concelier-webservice.slnf",
|
||||
"projects": [
|
||||
"src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj",
|
||||
"src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/StellaOps.Telemetry.Core.Tests.csproj",
|
||||
"src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user