using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Telemetry;
///
/// 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.
///
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 _logger;
private readonly bool _verbose;
public TraceparentHttpMessageHandler(
ILogger logger,
bool verbose = false)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_verbose = verbose;
}
protected override async Task 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}";
}
}
///
/// Extension to add traceparent propagation to HTTP client.
///
public static class TraceparentHttpClientBuilderExtensions
{
///
/// Adds W3C Trace Context (traceparent) header propagation to the HTTP client.
/// Per CLI-OBS-50-001.
///
public static IHttpClientBuilder AddTraceparentPropagation(
this IHttpClientBuilder builder,
bool verbose = false)
{
return builder.AddHttpMessageHandler(sp =>
{
var loggerFactory = sp.GetRequiredService();
return new TraceparentHttpMessageHandler(
loggerFactory.CreateLogger(),
verbose);
});
}
}