This commit is contained in:
StellaOps Bot
2025-11-27 21:10:06 +02:00
parent cfa2274d31
commit 8abbf9574d
106 changed files with 7078 additions and 3197 deletions

View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<!-- Override repo defaults to keep telemetry tests self-contained -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<ConcelierTestingPath></ConcelierTestingPath>
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
</PropertyGroup>
</Project>

View File

@@ -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>

View File

@@ -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"));
}
}

View File

@@ -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" />

View File

@@ -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));
}
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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" />

View File

@@ -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>

View File

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

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View 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"
]
}
}