up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP message handler that propagates W3C Trace Context (traceparent) headers.
|
||||
/// Per CLI-OBS-50-001, ensures CLI HTTP client propagates traceparent headers for all commands,
|
||||
/// prints correlation IDs on failure, and records trace IDs in verbose logs.
|
||||
/// </summary>
|
||||
public sealed class TraceparentHttpMessageHandler : DelegatingHandler
|
||||
{
|
||||
private const string TraceparentHeader = "traceparent";
|
||||
private const string TracestateHeader = "tracestate";
|
||||
private const string RequestIdHeader = "x-request-id";
|
||||
private const string CorrelationIdHeader = "x-correlation-id";
|
||||
|
||||
private readonly ILogger<TraceparentHttpMessageHandler> _logger;
|
||||
private readonly bool _verbose;
|
||||
|
||||
public TraceparentHttpMessageHandler(
|
||||
ILogger<TraceparentHttpMessageHandler> logger,
|
||||
bool verbose = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verbose = verbose;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
string? traceId = null;
|
||||
string? spanId = null;
|
||||
|
||||
// Generate or use existing trace context
|
||||
if (activity is not null)
|
||||
{
|
||||
traceId = activity.TraceId.ToString();
|
||||
spanId = activity.SpanId.ToString();
|
||||
|
||||
// Add W3C traceparent header if not already present
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-{(activity.Recorded ? "01" : "00")}";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Added traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tracestate if present
|
||||
if (!string.IsNullOrWhiteSpace(activity.TraceStateString) &&
|
||||
!request.Headers.Contains(TracestateHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TracestateHeader, activity.TraceStateString);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate a new trace ID if no activity exists
|
||||
traceId = Guid.NewGuid().ToString("N");
|
||||
spanId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-00";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Generated new traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add x-request-id for legacy compatibility
|
||||
if (!request.Headers.Contains(RequestIdHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(RequestIdHeader, traceId);
|
||||
}
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Sending {Method} {Uri} with trace_id={TraceId}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Request failed: {Method} {Uri} trace_id={TraceId} error={Error}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Extract correlation ID from response if present
|
||||
var responseTraceId = GetResponseTraceId(response);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Request returned {StatusCode}: {Method} {Uri} trace_id={TraceId} response_trace_id={ResponseTraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
responseTraceId ?? "(not provided)");
|
||||
}
|
||||
else if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Request completed {StatusCode}: {Method} {Uri} trace_id={TraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string? GetResponseTraceId(HttpResponseMessage response)
|
||||
{
|
||||
if (response.Headers.TryGetValues(CorrelationIdHeader, out var correlationValues))
|
||||
{
|
||||
return string.Join(",", correlationValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues(RequestIdHeader, out var requestIdValues))
|
||||
{
|
||||
return string.Join(",", requestIdValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues("x-trace-id", out var traceIdValues))
|
||||
{
|
||||
return string.Join(",", traceIdValues);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ScrubUrl(Uri? uri)
|
||||
{
|
||||
if (uri is null)
|
||||
return "(null)";
|
||||
|
||||
// Remove query string to avoid logging sensitive parameters
|
||||
return $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension to add traceparent propagation to HTTP client.
|
||||
/// </summary>
|
||||
public static class TraceparentHttpClientBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds W3C Trace Context (traceparent) header propagation to the HTTP client.
|
||||
/// Per CLI-OBS-50-001.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddTraceparentPropagation(
|
||||
this IHttpClientBuilder builder,
|
||||
bool verbose = false)
|
||||
{
|
||||
return builder.AddHttpMessageHandler(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new TraceparentHttpMessageHandler(
|
||||
loggerFactory.CreateLogger<TraceparentHttpMessageHandler>(),
|
||||
verbose);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user