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();
}