diff --git a/framework/docs/telemetry/README.md b/framework/docs/telemetry/README.md index b7fe778..8fff7fb 100644 --- a/framework/docs/telemetry/README.md +++ b/framework/docs/telemetry/README.md @@ -92,7 +92,9 @@ Config section key: `Telemetry` or `Aether:Telemetry`. "ParseStateValues": true, "Enrichers": { "CustomAttributes": { "env": "Production", "team": "platform" }, - "Headers": ["x-correlation-id", "x-request-id"] + "Headers": ["x-correlation-id", "x-request-id"], + "RequestHeaderKeyPrefix": "RequestHeader.", + "ResponseHeaderKeyPrefix": "ResponseHeader." }, "Body": { "EnableRequestBody": false, @@ -278,7 +280,8 @@ public class OrderMetrics `Telemetry:Logging:Enrichers` (CustomAttributes and Headers) are added as attributes to **every** log record in the application via `EnricherLogProcessor`. This allows querying all logs with the same enrich fields (e.g. `env`, `app`, `RequestHeader.x_correlation_id`) in a single observability query. - **CustomAttributes**: Key-value pairs added to every log. -- **Headers**: Listed request/response headers are added as `RequestHeader.` and `ResponseHeader.`; values for headers in the sensitive list are redacted. When there is no HTTP context (e.g. background job), only CustomAttributes are added. +- **Headers**: Listed request/response headers are added as `RequestHeader.` and `ResponseHeader.`; values for headers in the sensitive list are redacted. When there is no HTTP context (e.g. background job), only CustomAttributes are added. The header name is normalized (lowercased, `-` replaced by `_`), so `X-Request-Id` becomes `RequestHeader.x_request_id`. +- **RequestHeaderKeyPrefix / ResponseHeaderKeyPrefix**: The prefixes above are configurable (defaults `RequestHeader.` / `ResponseHeader.`). Log backends that cannot store dots in flat field names — OpenObserve, Elasticsearch — lowercase the key and replace `.` with `_` on ingest, so `RequestHeader.act_sub` is queried as `requestheader_act_sub`. Set the prefix to `""` to emit the bare header name (`act_sub`) instead. Request and response keys are distinguished only by these prefixes: giving both the same value (for example both empty) makes a header present on both request and response collapse onto a single key. ### Automatic Enrichment diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/EnricherLogProcessor.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/EnricherLogProcessor.cs index 331eaa4..049d521 100644 --- a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/EnricherLogProcessor.cs +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/EnricherLogProcessor.cs @@ -38,15 +38,16 @@ public override void OnEnd(LogRecord record) } var httpContext = httpContextAccessor?.HttpContext; - if (httpContext != null && options.Logging?.Enrichers?.Headers is { Count: > 0 } headerNames) + if (httpContext != null && options.Logging?.Enrichers is { } enrichers + && enrichers.Headers is { Count: > 0 } headerNames) { foreach (var headerName in headerNames) { if (string.IsNullOrWhiteSpace(headerName)) continue; var key = headerName.Trim(); - var requestKey = $"RequestHeader.{NormalizeHeaderKey(key)}"; - var responseKey = $"ResponseHeader.{NormalizeHeaderKey(key)}"; + var requestKey = HeaderEnrichmentKeys.Request(enrichers, key); + var responseKey = HeaderEnrichmentKeys.Response(enrichers, key); var isSensitive = _sensitiveHeaderNames.Contains(key); if (httpContext.Request.Headers.TryGetValue(key, out var reqVal)) @@ -89,7 +90,4 @@ private static bool HasBodyOrHeaderEnrichment(LogRecord record) } return false; } - - private static string NormalizeHeaderKey(string key) - => key.Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant(); } diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HeaderEnrichmentKeys.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HeaderEnrichmentKeys.cs new file mode 100644 index 0000000..07e57d8 --- /dev/null +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HeaderEnrichmentKeys.cs @@ -0,0 +1,33 @@ +using System; + +namespace BBT.Aether.AspNetCore.Telemetry; + +/// +/// Builds the attribute keys used to enrich log records with individual HTTP header values. +/// Shared by (all log records) and +/// (the HTTP body log scope) so the two paths cannot +/// drift apart and both honour the configured prefixes. +/// +internal static class HeaderEnrichmentKeys +{ + /// + /// Normalizes a header name for use in an attribute key: lowercase, with '-' replaced by '_' + /// (e.g. X-Request-Id becomes x_request_id). + /// + internal static string Normalize(string headerName) + => headerName.Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant(); + + /// + /// Builds the enrich key for a header read from the request, honouring + /// . + /// + internal static string Request(LoggingEnricherOptions options, string headerName) + => $"{options.RequestHeaderKeyPrefix ?? string.Empty}{Normalize(headerName)}"; + + /// + /// Builds the enrich key for a header read from the response, honouring + /// . + /// + internal static string Response(LoggingEnricherOptions options, string headerName) + => $"{options.ResponseHeaderKeyPrefix ?? string.Empty}{Normalize(headerName)}"; +} diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HttpBodyLoggingMiddleware.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HttpBodyLoggingMiddleware.cs index 230ada2..d02c2d7 100644 --- a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HttpBodyLoggingMiddleware.cs +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/HttpBodyLoggingMiddleware.cs @@ -346,8 +346,8 @@ private void AddEnricherProperties( if (string.IsNullOrWhiteSpace(headerName)) continue; var key = headerName.Trim(); - var requestKey = $"RequestHeader.{key.Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant()}"; - var responseKey = $"ResponseHeader.{key.Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant()}"; + var requestKey = HeaderEnrichmentKeys.Request(_enricherOptions, key); + var responseKey = HeaderEnrichmentKeys.Response(_enricherOptions, key); var isSensitive = _sensitiveHeaderNames.Contains(key); if (context.Request.Headers.TryGetValue(key, out var reqVal)) diff --git a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs index 786e4ad..f51f7ef 100644 --- a/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs +++ b/framework/src/BBT.Aether.AspNetCore/BBT/Aether/AspNetCore/Telemetry/TelemetryOptions.cs @@ -110,6 +110,29 @@ public sealed class LoggingEnricherOptions /// Header names to add as individual enrich properties (e.g. RequestHeader.x_correlation_id). Values for headers in the sensitive list are shown as ***REDACTED***. Full request/response headers remain in RequestHeaders/ResponseHeaders JSON. /// public List Headers { get; set; } = new(); + + /// + /// Prefix applied to request-header enrich keys. Defaults to "RequestHeader.", so the + /// header x-correlation-id is emitted as RequestHeader.x_correlation_id. + /// + /// Set to an empty string to emit the bare (normalized) header name instead — useful for log + /// backends such as OpenObserve or Elasticsearch, which cannot store dots in flat field names + /// and therefore lowercase the key and replace . with _ on ingest, turning + /// RequestHeader.act_sub into requestheader_act_sub. + /// + /// + /// Note: request and response keys are distinguished only by these prefixes. Setting this and + /// to the same value (for example both empty) makes a + /// header present on both the request and the response collapse onto a single key. + /// + /// + public string RequestHeaderKeyPrefix { get; set; } = "RequestHeader."; + + /// + /// Prefix applied to response-header enrich keys. Defaults to "ResponseHeader.". + /// See for the prefix semantics and the collision note. + /// + public string ResponseHeaderKeyPrefix { get; set; } = "ResponseHeader."; } public sealed class AetherTracingOptions