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 @@
+
+