From 5deb34baddd211b95c225b8c04ebaf5ff0a2cc3c Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 20 Jul 2026 18:14:56 -0400 Subject: [PATCH] Add OTLP logs signal to .NET observability SDK (th-5dca7d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send LOGS into the product /v1/logs OTLP endpoint, correlated to traces, matching the traces/metrics setup (ADR-100). The logs signal hooks the host's ILoggingBuilder — the standard OTel logging path — rather than the standalone tracer/meter providers in ObservabilitySdk.Setup. - New SmooObservabilityLoggingExtensions.AddSmooObservability(ILoggingBuilder): env-driven (SMOOAI_OBSERVABILITY_ENDPOINT → /v1/logs) and explicit overloads. Same endpoint, same auth (static token or M2M TokenProvider), same enable flag as traces/metrics. No new config keys. - AddOpenTelemetry() on ILoggingBuilder with IncludeScopes / ParseStateValues / IncludeFormattedMessage so structured state → attributes and the message → body. trace_id/span_id come from the active Activity automatically. - No-op when disabled or no logs endpoint (existing logging untouched). - Refactor: share the resource builder + exporter config + auth resolution between traces/metrics and logs (BuildResource, ConfigureExporter, ResolveAuth). Reuses the existing OpenTelemetry 1.16.0 exporter package — no new deps in the library. Test adds OpenTelemetry.Exporter.InMemory to prove a log emitted inside an active Activity carries that span's real W3C trace/span id. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S2bM94GAnVjYSSv1x7HKRB --- dotnet/src/SmooAI.Observability/Bootstrap.cs | 63 +++++++----- .../Otel/LoggingExtensions.cs | 89 +++++++++++++++++ .../Otel/ObservabilitySdk.cs | 23 ++++- .../OtelLoggingTests.cs | 97 +++++++++++++++++++ .../SmooAI.Observability.Tests.csproj | 2 + 5 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 dotnet/src/SmooAI.Observability/Otel/LoggingExtensions.cs create mode 100644 dotnet/tests/SmooAI.Observability.Tests/OtelLoggingTests.cs diff --git a/dotnet/src/SmooAI.Observability/Bootstrap.cs b/dotnet/src/SmooAI.Observability/Bootstrap.cs index 8d43381..46cef86 100644 --- a/dotnet/src/SmooAI.Observability/Bootstrap.cs +++ b/dotnet/src/SmooAI.Observability/Bootstrap.cs @@ -18,6 +18,9 @@ public sealed class BootstrapEnv /// Explicit metrics endpoint override. public string? MetricsEndpoint { get; set; } + /// Explicit logs endpoint override. + public string? LogsEndpoint { get; set; } + /// Pre-minted Bearer JWT (wins over client-credentials when both set). public string? Token { get; set; } @@ -97,25 +100,11 @@ public static async Task Run(BootstrapEnv? overrides = null) try { - TokenProvider? tokenProvider = null; - Dictionary? staticHeaders = null; + var (tokenProvider, staticHeaders) = ResolveAuth(env, warn: true); - if (!string.IsNullOrEmpty(env.Token)) - { - staticHeaders = new Dictionary(StringComparer.Ordinal) - { - ["authorization"] = $"Bearer {env.Token}", - }; - } - else if (!string.IsNullOrEmpty(env.AuthUrl) && !string.IsNullOrEmpty(env.ClientId) && !string.IsNullOrEmpty(env.ClientSecret)) + // Warm-up mint so the first export doesn't pay the round trip. + if (tokenProvider is not null) { - tokenProvider = new TokenProvider(new TokenProviderOptions - { - AuthUrl = env.AuthUrl!, - ClientId = env.ClientId!, - ClientSecret = env.ClientSecret!, - }); - // Warm-up mint so the first export doesn't pay the round trip. try { await tokenProvider.GetAccessTokenAsync().ConfigureAwait(false); @@ -125,10 +114,6 @@ public static async Task Run(BootstrapEnv? overrides = null) Warn($"initial token mint failed; OTLP exports will retry on first export: {ex.Message}"); } } - else - { - Warn("no auth configured (set SMOOAI_OBSERVABILITY_TOKEN or _AUTH_URL/_CLIENT_ID/_CLIENT_SECRET); OTLP exports will be unauthenticated"); - } var tracesEndpoint = env.TracesEndpoint ?? (env.Endpoint is not null ? $"{StripSlash(env.Endpoint)}/v1/traces" : null); var metricsEndpoint = env.MetricsEndpoint ?? (env.Endpoint is not null ? $"{StripSlash(env.Endpoint)}/v1/metrics" : null); @@ -163,7 +148,40 @@ public static async Task Run(BootstrapEnv? overrides = null) } } - private static BootstrapEnv ResolveEnv(BootstrapEnv? overrides) + /// + /// Resolve auth from env: a pre-minted Bearer becomes a static header; M2M + /// client-credentials become a lazy-minting (no + /// warm-up here — the caller decides whether to pre-mint). Shared by + /// and the logging extension so all signals authenticate identically. + /// + internal static (TokenProvider? TokenProvider, Dictionary? StaticHeaders) ResolveAuth(BootstrapEnv env, bool warn) + { + if (!string.IsNullOrEmpty(env.Token)) + { + return (null, new Dictionary(StringComparer.Ordinal) + { + ["authorization"] = $"Bearer {env.Token}", + }); + } + + if (!string.IsNullOrEmpty(env.AuthUrl) && !string.IsNullOrEmpty(env.ClientId) && !string.IsNullOrEmpty(env.ClientSecret)) + { + return (new TokenProvider(new TokenProviderOptions + { + AuthUrl = env.AuthUrl!, + ClientId = env.ClientId!, + ClientSecret = env.ClientSecret!, + }), null); + } + + if (warn) + { + Warn("no auth configured (set SMOOAI_OBSERVABILITY_TOKEN or _AUTH_URL/_CLIENT_ID/_CLIENT_SECRET); OTLP exports will be unauthenticated"); + } + return (null, null); + } + + internal static BootstrapEnv ResolveEnv(BootstrapEnv? overrides) { string? Env(string key) => System.Environment.GetEnvironmentVariable(key); return new BootstrapEnv @@ -171,6 +189,7 @@ private static BootstrapEnv ResolveEnv(BootstrapEnv? overrides) Endpoint = overrides?.Endpoint ?? Env("SMOOAI_OBSERVABILITY_ENDPOINT"), TracesEndpoint = overrides?.TracesEndpoint ?? Env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), MetricsEndpoint = overrides?.MetricsEndpoint ?? Env("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"), + LogsEndpoint = overrides?.LogsEndpoint ?? Env("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"), Token = overrides?.Token ?? Env("SMOOAI_OBSERVABILITY_TOKEN"), AuthUrl = overrides?.AuthUrl ?? Env("SMOOAI_OBSERVABILITY_AUTH_URL"), ClientId = overrides?.ClientId ?? Env("SMOOAI_OBSERVABILITY_CLIENT_ID"), diff --git a/dotnet/src/SmooAI.Observability/Otel/LoggingExtensions.cs b/dotnet/src/SmooAI.Observability/Otel/LoggingExtensions.cs new file mode 100644 index 0000000..6cc7871 --- /dev/null +++ b/dotnet/src/SmooAI.Observability/Otel/LoggingExtensions.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; +using OpenTelemetry.Logs; +using SmooAI.Observability.Auth; + +namespace SmooAI.Observability.Otel; + +/// +/// Wires the Smoo logs signal into a host's . +/// Logs export via OTLP/HTTP to the same ingest endpoint as traces/metrics +/// (SMOOAI_OBSERVABILITY_ENDPOINT/v1/logs) with the same auth — +/// so a single deployment config drives all three signals. Logs are correlated +/// to traces automatically: the OTel logging provider stamps each record with the +/// active 's W3C trace/span id. +/// +/// Unlike traces/metrics (standalone providers built in ), +/// the logs signal must hook the host's logging pipeline, so it lives here as an +/// extension. +/// +public static class SmooObservabilityLoggingExtensions +{ + /// + /// Env-driven wiring. Reads the same SMOOAI_OBSERVABILITY_* variables as + /// ; the logs endpoint is OTEL_EXPORTER_OTLP_LOGS_ENDPOINT + /// or {SMOOAI_OBSERVABILITY_ENDPOINT}/v1/logs. No-op (existing logging + /// unchanged) when disabled or no endpoint is configured. + /// + public static ILoggingBuilder AddSmooObservability(this ILoggingBuilder builder, BootstrapEnv? overrides = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var env = Bootstrap.ResolveEnv(overrides); + if (env.Disabled == true) + { + return builder; + } + + var logsEndpoint = env.LogsEndpoint + ?? (env.Endpoint is not null ? $"{env.Endpoint.TrimEnd('/')}/v1/logs" : null); + if (string.IsNullOrEmpty(logsEndpoint)) + { + return builder; + } + + var (tokenProvider, staticHeaders) = Bootstrap.ResolveAuth(env, warn: false); + + return builder.AddSmooObservability(new SetupOtelOptions + { + ServiceName = env.ServiceName ?? "smoo-service", + Environment = env.Environment, + Release = env.Release, + OtlpLogsEndpoint = logsEndpoint, + TokenProvider = tokenProvider, + OtlpHeaders = staticHeaders, + }); + } + + /// + /// Explicit wiring. Adds the OpenTelemetry logging provider with an OTLP/HTTP + /// exporter pointed at . No-op when + /// that endpoint is null/empty, so existing log output is untouched when logs + /// export isn't configured. + /// + public static ILoggingBuilder AddSmooObservability(this ILoggingBuilder builder, SetupOtelOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + if (string.IsNullOrEmpty(options.OtlpLogsEndpoint)) + { + return builder; + } + + var resourceBuilder = ObservabilitySdk.BuildResource(options); + + builder.AddOpenTelemetry(logging => + { + logging.SetResourceBuilder(resourceBuilder); + // Turn structured logging state + scopes into log-record attributes + // (→ product `parsed_fields`), and keep the rendered message as the body. + logging.IncludeScopes = true; + logging.ParseStateValues = true; + logging.IncludeFormattedMessage = true; + logging.AddOtlpExporter(exporter => + ObservabilitySdk.ConfigureExporter(exporter, options.OtlpLogsEndpoint!, options)); + }); + + return builder; + } +} diff --git a/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs b/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs index 13b718c..1764b72 100644 --- a/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs +++ b/dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs @@ -24,6 +24,14 @@ public sealed class SetupOtelOptions /// OTLP/HTTP endpoint for metrics. Full URL incl. /v1/metrics. public string? OtlpMetricsEndpoint { get; set; } + /// + /// OTLP/HTTP endpoint for logs. Full URL incl. /v1/logs. Consumed by the + /// ILoggingBuilder.AddSmooObservability extension — the logs signal wires + /// into the host's logging pipeline, not the standalone + /// tracer/meter providers. + /// + public string? OtlpLogsEndpoint { get; set; } + /// Deployment environment string ('production', 'staging', 'dev', 'local'). public string? Environment { get; set; } @@ -172,7 +180,12 @@ public static OtelSdkHandle Setup(SetupOtelOptions options) } } - private static OtelSdkHandle Build(SetupOtelOptions options) + /// + /// Build the shared OTel service.name plus + /// optional service.version / deployment.environment.name. Reused by + /// traces, metrics, and the logs signal so all three carry the same resource. + /// + internal static ResourceBuilder BuildResource(SetupOtelOptions options) { var resourceBuilder = ResourceBuilder.CreateDefault().AddService(options.ServiceName); var resourceAttrs = new List>(); @@ -188,6 +201,12 @@ private static OtelSdkHandle Build(SetupOtelOptions options) { resourceBuilder.AddAttributes(resourceAttrs); } + return resourceBuilder; + } + + private static OtelSdkHandle Build(SetupOtelOptions options) + { + var resourceBuilder = BuildResource(options); TracerProvider? tracerProvider = null; MeterProvider? meterProvider = null; @@ -243,7 +262,7 @@ private static OtelSdkHandle Build(SetupOtelOptions options) return new OtelSdkHandle(tracerProvider, meterProvider); } - private static void ConfigureExporter(OtlpExporterOptions exporterOptions, string endpoint, SetupOtelOptions options) + internal static void ConfigureExporter(OtlpExporterOptions exporterOptions, string endpoint, SetupOtelOptions options) { exporterOptions.Endpoint = new Uri(endpoint); exporterOptions.Protocol = OtlpExportProtocol.HttpProtobuf; diff --git a/dotnet/tests/SmooAI.Observability.Tests/OtelLoggingTests.cs b/dotnet/tests/SmooAI.Observability.Tests/OtelLoggingTests.cs new file mode 100644 index 0000000..d648a0a --- /dev/null +++ b/dotnet/tests/SmooAI.Observability.Tests/OtelLoggingTests.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Logs; +using SmooAI.Observability.Otel; + +namespace SmooAI.Observability.Tests; + +/// +/// Verifies the logs signal wired by : +/// records emitted inside an active carry that span's W3C +/// trace/span id, and the extension is a no-op when no logs endpoint is configured. +/// +public class OtelLoggingTests +{ + private static bool HasOtelProvider(IServiceCollection services) + { + using var sp = services.BuildServiceProvider(); + return sp.GetServices() + .Any(p => p.GetType().Namespace?.StartsWith("OpenTelemetry", StringComparison.Ordinal) == true); + } + + [Fact] + public void NoLogsEndpoint_IsNoOp_NoOtelProviderRegistered() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddSmooObservability(new SetupOtelOptions + { + ServiceName = "svc", + OtlpLogsEndpoint = null, + })); + + Assert.False(HasOtelProvider(services)); + } + + [Fact] + public void LogsEndpoint_RegistersOtelProvider() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddSmooObservability(new SetupOtelOptions + { + ServiceName = "svc", + OtlpLogsEndpoint = "https://ingest.test/v1/logs", + })); + + Assert.True(HasOtelProvider(services)); + } + + [Fact] + public void Disabled_Env_IsNoOp() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddSmooObservability(new BootstrapEnv + { + Endpoint = "https://ingest.test", + Disabled = true, + })); + + Assert.False(HasOtelProvider(services)); + } + + [Fact] + public void LogWithinActiveActivity_CarriesRealTraceAndSpanId() + { + using var source = new ActivitySource("smooai.observability.logtests"); + using var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == "smooai.observability.logtests", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + + var records = new List(); + using var factory = LoggerFactory.Create(b => + { + // The extension wires the OTel logging provider (OTLP → fake endpoint, + // never reached); capture the same records in-memory to assert on them. + b.AddSmooObservability(new SetupOtelOptions + { + ServiceName = "svc", + OtlpLogsEndpoint = "http://localhost:1/v1/logs", + }); + b.Services.Configure(o => o.AddInMemoryExporter(records)); + }); + + var logger = factory.CreateLogger("test"); + using (var activity = source.StartActivity("op")) + { + Assert.NotNull(activity); + logger.LogInformation("inside span"); + + var record = Assert.Single(records); + Assert.Equal(activity!.TraceId, record.TraceId); + Assert.Equal(activity.SpanId, record.SpanId); + } + } +} diff --git a/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj b/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj index f816764..08447cb 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj +++ b/dotnet/tests/SmooAI.Observability.Tests/SmooAI.Observability.Tests.csproj @@ -15,6 +15,8 @@ + +