Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions dotnet/src/SmooAI.Observability/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public sealed class BootstrapEnv
/// <summary>Explicit metrics endpoint override.</summary>
public string? MetricsEndpoint { get; set; }

/// <summary>Explicit logs endpoint override.</summary>
public string? LogsEndpoint { get; set; }

/// <summary>Pre-minted Bearer JWT (wins over client-credentials when both set).</summary>
public string? Token { get; set; }

Expand Down Expand Up @@ -97,25 +100,11 @@ public static async Task<BootstrapResult> Run(BootstrapEnv? overrides = null)

try
{
TokenProvider? tokenProvider = null;
Dictionary<string, string>? staticHeaders = null;
var (tokenProvider, staticHeaders) = ResolveAuth(env, warn: true);

if (!string.IsNullOrEmpty(env.Token))
{
staticHeaders = new Dictionary<string, string>(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);
Expand All @@ -125,10 +114,6 @@ public static async Task<BootstrapResult> 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);
Expand Down Expand Up @@ -163,14 +148,48 @@ public static async Task<BootstrapResult> Run(BootstrapEnv? overrides = null)
}
}

private static BootstrapEnv ResolveEnv(BootstrapEnv? overrides)
/// <summary>
/// Resolve auth from env: a pre-minted Bearer becomes a static header; M2M
/// client-credentials become a lazy-minting <see cref="TokenProvider"/> (no
/// warm-up here — the caller decides whether to pre-mint). Shared by
/// <see cref="Run"/> and the logging extension so all signals authenticate identically.
/// </summary>
internal static (TokenProvider? TokenProvider, Dictionary<string, string>? StaticHeaders) ResolveAuth(BootstrapEnv env, bool warn)
{
if (!string.IsNullOrEmpty(env.Token))
{
return (null, new Dictionary<string, string>(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
{
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"),
Expand Down
89 changes: 89 additions & 0 deletions dotnet/src/SmooAI.Observability/Otel/LoggingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using Microsoft.Extensions.Logging;
using OpenTelemetry.Logs;
using SmooAI.Observability.Auth;

namespace SmooAI.Observability.Otel;

/// <summary>
/// Wires the Smoo <b>logs</b> signal into a host's <see cref="ILoggingBuilder"/>.
/// Logs export via OTLP/HTTP to the same ingest endpoint as traces/metrics
/// (<c>SMOOAI_OBSERVABILITY_ENDPOINT</c> → <c>/v1/logs</c>) 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 <see cref="System.Diagnostics.Activity"/>'s W3C trace/span id.
///
/// Unlike traces/metrics (standalone providers built in <see cref="ObservabilitySdk.Setup"/>),
/// the logs signal must hook the host's logging pipeline, so it lives here as an
/// <see cref="ILoggingBuilder"/> extension.
/// </summary>
public static class SmooObservabilityLoggingExtensions
{
/// <summary>
/// Env-driven wiring. Reads the same <c>SMOOAI_OBSERVABILITY_*</c> variables as
/// <see cref="Bootstrap"/>; the logs endpoint is <c>OTEL_EXPORTER_OTLP_LOGS_ENDPOINT</c>
/// or <c>{SMOOAI_OBSERVABILITY_ENDPOINT}/v1/logs</c>. No-op (existing logging
/// unchanged) when disabled or no endpoint is configured.
/// </summary>
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,
});
}

/// <summary>
/// Explicit wiring. Adds the OpenTelemetry logging provider with an OTLP/HTTP
/// exporter pointed at <see cref="SetupOtelOptions.OtlpLogsEndpoint"/>. No-op when
/// that endpoint is null/empty, so existing log output is untouched when logs
/// export isn't configured.
/// </summary>
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;
}
}
23 changes: 21 additions & 2 deletions dotnet/src/SmooAI.Observability/Otel/ObservabilitySdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public sealed class SetupOtelOptions
/// <summary>OTLP/HTTP endpoint for metrics. Full URL incl. <c>/v1/metrics</c>.</summary>
public string? OtlpMetricsEndpoint { get; set; }

/// <summary>
/// OTLP/HTTP endpoint for logs. Full URL incl. <c>/v1/logs</c>. Consumed by the
/// <c>ILoggingBuilder.AddSmooObservability</c> extension — the logs signal wires
/// into the host's logging pipeline, not the standalone <see cref="ObservabilitySdk.Setup"/>
/// tracer/meter providers.
/// </summary>
public string? OtlpLogsEndpoint { get; set; }

/// <summary>Deployment environment string ('production', 'staging', 'dev', 'local').</summary>
public string? Environment { get; set; }

Expand Down Expand Up @@ -172,7 +180,12 @@ public static OtelSdkHandle Setup(SetupOtelOptions options)
}
}

private static OtelSdkHandle Build(SetupOtelOptions options)
/// <summary>
/// Build the shared OTel <see cref="ResourceBuilder"/> — <c>service.name</c> plus
/// optional <c>service.version</c> / <c>deployment.environment.name</c>. Reused by
/// traces, metrics, and the logs signal so all three carry the same resource.
/// </summary>
internal static ResourceBuilder BuildResource(SetupOtelOptions options)
{
var resourceBuilder = ResourceBuilder.CreateDefault().AddService(options.ServiceName);
var resourceAttrs = new List<KeyValuePair<string, object>>();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
97 changes: 97 additions & 0 deletions dotnet/tests/SmooAI.Observability.Tests/OtelLoggingTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies the logs signal wired by <see cref="SmooObservabilityLoggingExtensions"/>:
/// records emitted inside an active <see cref="Activity"/> carry that span's W3C
/// trace/span id, and the extension is a no-op when no logs endpoint is configured.
/// </summary>
public class OtelLoggingTests
{
private static bool HasOtelProvider(IServiceCollection services)
{
using var sp = services.BuildServiceProvider();
return sp.GetServices<ILoggerProvider>()
.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<ActivityContext> _) => ActivitySamplingResult.AllData,
};
ActivitySource.AddActivityListener(listener);

var records = new List<LogRecord>();
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<OpenTelemetryLoggerOptions>(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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<!-- In-memory OTLP log-record capture for the logs-signal correlation test. -->
<PackageReference Include="OpenTelemetry.Exporter.InMemory" Version="1.16.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
Expand Down
Loading