feat(telemetry.serilog): ILogRedactor seam + OTel log export
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seam for project-specific log-event redaction. The shared library applies this via
|
||||||
|
/// <see cref="RedactionEnricher"/>; 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 <see cref="ILogRedactor"/> is registered in DI, <see cref="RedactionEnricher"/> is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILogRedactor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inspects and mutates the supplied log-event <paramref name="properties"/> in place — remove
|
||||||
|
/// or replace any sensitive values. Called on every log event before it reaches any sink.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">The mutable property dictionary for the current log event.</param>
|
||||||
|
void Redact(IDictionary<string, object?> properties);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a registered <see cref="ILogRedactor"/> to every Serilog log event. Registered
|
||||||
|
/// automatically by <see cref="ZbSerilogExtensions.AddZbSerilog"/>. The enricher resolves
|
||||||
|
/// <see cref="ILogRedactor"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RedactionEnricher : ILogEventEnricher
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private ILogRedactor? _redactor;
|
||||||
|
private bool _resolved;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the enricher bound to a service provider from which the project-supplied
|
||||||
|
/// <see cref="ILogRedactor"/> is resolved lazily on first use.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceProvider">Provider used to resolve a registered <see cref="ILogRedactor"/>.</param>
|
||||||
|
public RedactionEnricher(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands the log event's scalar properties to the registered <see cref="ILogRedactor"/> and
|
||||||
|
/// writes back any values the redactor changed. No-op when no redactor is registered.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logEvent">The log event to redact.</param>
|
||||||
|
/// <param name="propertyFactory">Factory used to materialize replacement properties.</param>
|
||||||
|
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<string, object?>(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<ILogRedactor>();
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Configuration;
|
using Serilog.Configuration;
|
||||||
|
using Serilog.Sinks.OpenTelemetry;
|
||||||
using ZB.MOM.WW.Telemetry;
|
using ZB.MOM.WW.Telemetry;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.Telemetry.Serilog;
|
namespace ZB.MOM.WW.Telemetry.Serilog;
|
||||||
@@ -66,6 +68,77 @@ public static class ZbSerilogConfig
|
|||||||
|
|
||||||
enrich.With(new TraceContextEnricher());
|
enrich.With(new TraceContextEnricher());
|
||||||
|
|
||||||
|
if (serviceProvider is not null)
|
||||||
|
{
|
||||||
|
enrich.With(new RedactionEnricher(serviceProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyOpenTelemetryExport(loggerConfiguration, options);
|
||||||
|
|
||||||
return loggerConfiguration;
|
return loggerConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a <c>WriteTo.OpenTelemetry</c> log sink when an OTLP exporter is configured
|
||||||
|
/// (<see cref="ZbTelemetryOptions.Exporter"/> = <see cref="ZbExporter.Otlp"/> or
|
||||||
|
/// <see cref="ZbTelemetryOptions.OtlpEndpoint"/> set). The sink carries the same Resource
|
||||||
|
/// attributes as <c>ZbResource</c> (<c>service.name</c>/<c>service.namespace</c>/
|
||||||
|
/// <c>service.version</c>/<c>site.id</c>/<c>node.role</c>/<c>host.name</c>) so logs correlate
|
||||||
|
/// with metrics and traces in the backend.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the OTLP Resource-attribute map mirroring <c>ZbResource</c>. Null/empty optional
|
||||||
|
/// attributes are omitted, matching the shared Resource's omission rules.
|
||||||
|
/// </summary>
|
||||||
|
private static IDictionary<string, object> BuildResourceAttributes(ZbTelemetryOptions options)
|
||||||
|
{
|
||||||
|
var attributes = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, object?> 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<ILogRedactor>(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<ILogger>();
|
||||||
|
logger.Information("otlp wiring smoke test");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user