From 2b856074d5ce5079289c3d5d37dc7616241fcf3f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:40:58 -0400 Subject: [PATCH] feat(telemetry.serilog): ILogRedactor seam + OTel log export --- .../ILogRedactor.cs | 17 ++++ .../RedactionEnricher.cs | 90 +++++++++++++++++++ .../ZbSerilogConfig.cs | 73 +++++++++++++++ .../RedactionTests.cs | 90 +++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs create mode 100644 ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs create mode 100644 ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs new file mode 100644 index 0000000..0df8683 --- /dev/null +++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ILogRedactor.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.Telemetry.Serilog; + +/// +/// Seam for project-specific log-event redaction. The shared library applies this via +/// ; each project provides its own implementation that knows which +/// fields (by property name) or which command payloads must not leave the process in log events. +/// If no is registered in DI, is a no-op. +/// +public interface ILogRedactor +{ + /// + /// Inspects and mutates the supplied log-event in place — remove + /// or replace any sensitive values. Called on every log event before it reaches any sink. + /// + /// The mutable property dictionary for the current log event. + void Redact(IDictionary properties); +} diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs new file mode 100644 index 0000000..86f6e91 --- /dev/null +++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/RedactionEnricher.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.DependencyInjection; +using Serilog.Core; +using Serilog.Events; + +namespace ZB.MOM.WW.Telemetry.Serilog; + +/// +/// Applies a registered to every Serilog log event. Registered +/// automatically by . The enricher resolves +/// from DI on first use (lazy, to avoid a circular-DI problem during +/// Serilog's bootstrap); if none is registered it is permanently inert — no DI call per event. +/// +public sealed class RedactionEnricher : ILogEventEnricher +{ + private readonly IServiceProvider _serviceProvider; + private ILogRedactor? _redactor; + private bool _resolved; + + /// + /// Creates the enricher bound to a service provider from which the project-supplied + /// is resolved lazily on first use. + /// + /// Provider used to resolve a registered . + public RedactionEnricher(IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + _serviceProvider = serviceProvider; + } + + /// + /// Hands the log event's scalar properties to the registered and + /// writes back any values the redactor changed. No-op when no redactor is registered. + /// + /// The log event to redact. + /// Factory used to materialize replacement properties. + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + var redactor = ResolveRedactor(); + if (redactor is null) + { + return; + } + + var snapshot = new Dictionary(logEvent.Properties.Count); + foreach (var property in logEvent.Properties) + { + snapshot[property.Key] = property.Value is ScalarValue scalar + ? scalar.Value + : property.Value; + } + + redactor.Redact(snapshot); + + foreach (var entry in snapshot) + { + if (HasChanged(logEvent, entry.Key, entry.Value)) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty(entry.Key, entry.Value)); + } + } + } + + private ILogRedactor? ResolveRedactor() + { + if (_resolved) + { + return _redactor; + } + + _redactor = _serviceProvider.GetService(); + _resolved = true; + return _redactor; + } + + private static bool HasChanged(LogEvent logEvent, string key, object? newValue) + { + if (!logEvent.Properties.TryGetValue(key, out var existing)) + { + // Redactor added a brand-new property. + return true; + } + + var existingValue = existing is ScalarValue scalar ? scalar.Value : existing; + return !Equals(existingValue, newValue); + } +} diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs index 927fe58..b77a975 100644 --- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs +++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using Serilog; using Serilog.Configuration; +using Serilog.Sinks.OpenTelemetry; using ZB.MOM.WW.Telemetry; namespace ZB.MOM.WW.Telemetry.Serilog; @@ -66,6 +68,77 @@ public static class ZbSerilogConfig enrich.With(new TraceContextEnricher()); + if (serviceProvider is not null) + { + enrich.With(new RedactionEnricher(serviceProvider)); + } + + ApplyOpenTelemetryExport(loggerConfiguration, options); + return loggerConfiguration; } + + /// + /// Adds a WriteTo.OpenTelemetry log sink when an OTLP exporter is configured + /// ( = or + /// set). The sink carries the same Resource + /// attributes as ZbResource (service.name/service.namespace/ + /// service.version/site.id/node.role/host.name) so logs correlate + /// with metrics and traces in the backend. + /// + private static void ApplyOpenTelemetryExport( + LoggerConfiguration loggerConfiguration, + ZbTelemetryOptions options) + { + var otlpRequested = options.Exporter == ZbExporter.Otlp + || !string.IsNullOrEmpty(options.OtlpEndpoint); + if (!otlpRequested) + { + return; + } + + var resourceAttributes = BuildResourceAttributes(options); + + loggerConfiguration.WriteTo.OpenTelemetry(sink => + { + if (!string.IsNullOrEmpty(options.OtlpEndpoint)) + { + sink.Endpoint = options.OtlpEndpoint; + } + + sink.Protocol = OtlpProtocol.Grpc; + sink.ResourceAttributes = resourceAttributes; + }); + } + + /// + /// Builds the OTLP Resource-attribute map mirroring ZbResource. Null/empty optional + /// attributes are omitted, matching the shared Resource's omission rules. + /// + private static IDictionary BuildResourceAttributes(ZbTelemetryOptions options) + { + var attributes = new Dictionary + { + ["service.name"] = options.ServiceName, + ["service.namespace"] = options.ServiceNamespace, + ["host.name"] = Environment.MachineName, + }; + + if (!string.IsNullOrEmpty(options.ServiceVersion)) + { + attributes["service.version"] = options.ServiceVersion; + } + + if (!string.IsNullOrEmpty(options.SiteId)) + { + attributes["site.id"] = options.SiteId; + } + + if (!string.IsNullOrEmpty(options.NodeRole)) + { + attributes["node.role"] = options.NodeRole; + } + + return attributes; + } } diff --git a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs new file mode 100644 index 0000000..045d1f9 --- /dev/null +++ b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/RedactionTests.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Sinks.InMemory; +using ZB.MOM.WW.Telemetry; +using ZB.MOM.WW.Telemetry.Serilog; + +namespace ZB.MOM.WW.Telemetry.Serilog.Tests; + +public sealed class RedactionTests +{ + private const string Masked = "***"; + + private sealed class FakeRedactor : ILogRedactor + { + public void Redact(IDictionary properties) + { + if (properties.ContainsKey("apiKey")) + { + properties["apiKey"] = Masked; + } + } + } + + private static string? ScalarOrNull(LogEvent logEvent, string propertyName) => + logEvent.Properties.TryGetValue(propertyName, out var value) && value is ScalarValue scalar + ? scalar.Value?.ToString() + : null; + + [Fact] + public void Registered_redactor_masks_sensitive_property() + { + var serviceProvider = new ServiceCollection() + .AddSingleton(new FakeRedactor()) + .BuildServiceProvider(); + + var sink = new InMemorySink(); + var options = new ZbTelemetryOptions { ServiceName = "mxgateway" }; + + var loggerConfig = new LoggerConfiguration(); + ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider); + using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger(); + + logger.Information("authenticating {apiKey}", "mxgw_secret"); + + var logEvent = Assert.Single(sink.LogEvents); + Assert.Equal(Masked, ScalarOrNull(logEvent, "apiKey")); + } + + [Fact] + public void No_redactor_registered_is_a_no_op() + { + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + + var sink = new InMemorySink(); + var options = new ZbTelemetryOptions { ServiceName = "mxgateway" }; + + var loggerConfig = new LoggerConfiguration(); + ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider); + using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger(); + + logger.Information("authenticating {apiKey}", "mxgw_secret"); + + var logEvent = Assert.Single(sink.LogEvents); + Assert.Equal("mxgw_secret", ScalarOrNull(logEvent, "apiKey")); + } + + [Fact] + public void AddZbSerilog_with_otlp_options_builds_without_error() + { + var builder = Host.CreateApplicationBuilder(); + + builder.AddZbSerilog(o => + { + o.ServiceName = "mxgateway"; + o.SiteId = "s1"; + o.NodeRole = "central"; + o.Exporter = ZbExporter.Otlp; + o.OtlpEndpoint = "http://localhost:4317"; + }); + + using var host = builder.Build(); + + var logger = host.Services.GetRequiredService(); + logger.Information("otlp wiring smoke test"); + } +}