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