using Microsoft.Extensions.Logging; using System.Diagnostics; namespace GB5Shared.Telemetry.Logging; /// /// Bridges every call from GB5 application code into the active /// OpenTelemetry as a span event (annotation), so all BLL, DAL, /// and service log statements appear in the trace timeline automatically — no per-class changes. /// /// Registered by when /// is true. /// Framework namespaces (Microsoft.*, System.*, Grpc.*, Dapr.*, FastEndpoints.*) are excluded. /// /// /// In Zipkin each event appears as an annotation on the span timeline. /// In Jaeger / Grafana Tempo each event appears as a structured log entry with tags. /// /// public sealed class SpanEventLoggerProvider : ILoggerProvider { private readonly TelemetryOptions _options; public SpanEventLoggerProvider(TelemetryOptions options) => _options = options; public ILogger CreateLogger(string categoryName) => new SpanEventLogger(categoryName, _options); public void Dispose() { } } internal sealed class SpanEventLogger : ILogger { private readonly string _category; private readonly string _eventName; // last segment of the category, used as event name private readonly TelemetryOptions _options; internal SpanEventLogger(string category, TelemetryOptions options) { _category = category; _options = options; var dot = category.LastIndexOf('.'); _eventName = dot >= 0 ? category[(dot + 1)..] : category; } public bool IsEnabled(LogLevel logLevel) { if (!_options.CaptureLogEvents) return false; if (logLevel < _options.LogEventMinLevel) return false; // Suppress framework namespaces — only keep application code if (_category.StartsWith("Microsoft.", StringComparison.Ordinal)) return false; if (_category.StartsWith("System.", StringComparison.Ordinal)) return false; if (_category.StartsWith("Grpc.", StringComparison.Ordinal)) return false; if (_category.StartsWith("Dapr.", StringComparison.Ordinal)) return false; if (_category.StartsWith("FastEndpoints.", StringComparison.Ordinal)) return false; if (_category.StartsWith("OpenTelemetry.", StringComparison.Ordinal)) return false; return true; } public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { // Guard: no active span → nothing to annotate var activity = Activity.Current; if (activity is null) return; if (!IsEnabled(logLevel)) return; var message = formatter(state, exception); if (string.IsNullOrEmpty(message)) return; const int maxMsg = 2000; if (message.Length > maxMsg) message = string.Concat(message.AsSpan(0, maxMsg), "…[truncated]"); var tags = new ActivityTagsCollection { ["log.level"] = logLevel.ToString(), ["log.category"] = _category, ["log.message"] = message, }; // Promote structured log state properties to individual event tags for searchability if (state is IReadOnlyList> props) { foreach (var (key, value) in props) { if (key is "{OriginalFormat}" || value is null) continue; tags[$"log.{SanitizeKey(key)}"] = value.ToString() ?? string.Empty; } } // Exception details — full chain captured as event tags if (exception is not null) { tags["exception.type"] = exception.GetType().FullName ?? exception.GetType().Name; tags["exception.message"] = exception.Message; if (exception.StackTrace is { Length: > 0 } st) { const int maxStack = 2000; tags["exception.stacktrace"] = st.Length > maxStack ? string.Concat(st.AsSpan(0, maxStack), "…") : st; } if (exception.InnerException is { } inner) { tags["exception.inner.type"] = inner.GetType().Name; tags["exception.inner.message"] = inner.Message; } } activity.AddEvent(new ActivityEvent(_eventName, tags: tags)); } public IDisposable? BeginScope(TState state) where TState : notnull => null; private static string SanitizeKey(string key) => key.Replace(' ', '_').Replace('.', '_').Replace('-', '_').ToLowerInvariant(); }