Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/bootstrap-honest-status-polyglot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@smooai/observability': minor
---

TypeScript, Go, Python and .NET bootstraps now report whether they are actually
EXPORTING, not just whether they ran, and warn loudly when no OTLP endpoint is
configured — the same fix already landed for Rust.

`installed` / `Installed` kept its old meaning (bootstrap ran) and now says so
honestly in its doc comment; the new `exporting` / `Exporting` answers the
question that actually matters: does telemetry have anywhere to go. In TS and
Python the no-endpoint case is worse than a no-op — the OTel exporters fall back
to `http://localhost:4318` and retry into the void forever.
44 changes: 40 additions & 4 deletions dotnet/src/SmooAI.Observability/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,25 @@ public sealed class BootstrapEnv
/// </summary>
public sealed class BootstrapResult
{
/// <summary>Whether the bootstrap actually ran.</summary>
/// <summary>
/// Whether the bootstrap actually ran (false = disabled or init threw).
/// <para>
/// NOTE: <c>Installed == true</c> only means bootstrap ran. It does NOT mean
/// anything is being exported — check <see cref="Exporting"/> for that. These
/// were the same flag until 2026-08-15, and the conflation hid a production
/// service emitting nothing for months: it had no endpoint configured, so no
/// OTLP exporter was ever built, yet bootstrap reported success.
/// </para>
/// </summary>
public bool Installed { get; init; }

/// <summary>
/// Whether an OTLP exporter was actually installed — i.e. whether spans and
/// metrics have somewhere to go. False when no endpoint is configured, in
/// which case this SDK is a no-op for telemetry.
/// </summary>
public bool Exporting { get; init; }

/// <summary>OTel handle (flush/shutdown). Null if init failed or was skipped.</summary>
public OtelSdkHandle? Otel { get; init; }
}
Expand Down Expand Up @@ -95,7 +111,7 @@ public static async Task<BootstrapResult> Run(BootstrapEnv? overrides = null)

if (env.Disabled == true)
{
return Cache(new BootstrapResult { Installed = false, Otel = null });
return Cache(new BootstrapResult { Installed = false, Exporting = false, Otel = null });
}

try
Expand All @@ -118,6 +134,26 @@ public static async Task<BootstrapResult> Run(BootstrapEnv? overrides = null)
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);

// The single most expensive silence this SDK can produce. With no
// endpoint nothing reaches a collector, yet every other signal (this
// result, the caller's own "observability enabled" log) still says
// healthy. Say it plainly and name the variable to set.
//
// Only traces + metrics count: ObservabilitySdk.Setup builds an
// exporter for exactly those two, and a LogsEndpoint alone is
// consumed by the ILoggingBuilder extension, not here — claiming
// Exporting on it would be the same lie in a new place.
var exporting = !string.IsNullOrEmpty(tracesEndpoint) || !string.IsNullOrEmpty(metricsEndpoint);
if (!exporting)
{
Warn(
"NO OTLP ENDPOINT CONFIGURED — telemetry is NOT being exported. " +
"Nothing this process emits will reach a collector. " +
"Set SMOOAI_OBSERVABILITY_ENDPOINT (or a per-signal " +
"OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT), or set " +
"SMOOAI_OBSERVABILITY_DISABLED=true to make this silence deliberate.");
}

var otel = ObservabilitySdk.Setup(new SetupOtelOptions
{
ServiceName = env.ServiceName ?? "smoo-service",
Expand Down Expand Up @@ -146,12 +182,12 @@ public static async Task<BootstrapResult> Run(BootstrapEnv? overrides = null)
// nobody remembers to opt into.
GlobalHandlers.Register(otel);

return Cache(new BootstrapResult { Installed = true, Otel = otel });
return Cache(new BootstrapResult { Installed = true, Exporting = exporting, Otel = otel });
}
catch (Exception ex)
{
Warn($"SDK init failed: {ex.Message}");
return Cache(new BootstrapResult { Installed = false, Otel = null });
return Cache(new BootstrapResult { Installed = false, Exporting = false, Otel = null });
}
}

Expand Down
116 changes: 110 additions & 6 deletions dotnet/tests/SmooAI.Observability.Tests/BootstrapTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace SmooAI.Observability.Tests;

[Collection("Bootstrap")]
[Collection(OtelGlobalStateCollection.Name)]
public class BootstrapTests
{
[Fact]
Expand Down Expand Up @@ -46,14 +46,118 @@ public async Task Run_NeverThrows_OnBadConfig()
Bootstrap.ResetForTests();
ObservabilitySdk.ResetForTests();

// No endpoint, no auth — must still return without throwing.
var result = await Bootstrap.Run(new BootstrapEnv { ServiceName = "svc" });
// ResolveEnv falls back to these, so "no endpoint" has to mean no
// endpoint from ANY source or the Exporting assertion below would be
// environment-dependent.
using var env = new ScopedEnv(
"SMOOAI_OBSERVABILITY_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT");
var stderr = new StringWriter();
var originalError = Console.Error;
Console.SetError(stderr);
try
{
// No endpoint, no auth — must still return without throwing.
var result = await Bootstrap.Run(new BootstrapEnv { ServiceName = "svc" });

Assert.NotNull(result);
Assert.NotNull(result);
Assert.True(result.Installed); // bootstrap ran…
// …but it is NOT exporting, and the result now says so. This
// assertion is the whole point: Installed alone used to be the only
// signal, and it reads as "everything is fine" while nothing leaves
// the process.
Assert.False(result.Exporting);
Assert.Contains("NO OTLP ENDPOINT CONFIGURED", stderr.ToString(), StringComparison.Ordinal);
Assert.Contains("SMOOAI_OBSERVABILITY_DISABLED=true", stderr.ToString(), StringComparison.Ordinal);
}
finally
{
Console.SetError(originalError);
Bootstrap.ResetForTests();
ObservabilitySdk.ResetForTests();
}
}

/// <summary>
/// The inverse of the no-endpoint case: with an endpoint configured the
/// result must claim it IS exporting. Without both halves asserted, a
/// regression that hard-codes either value passes.
/// </summary>
[Fact]
public async Task Run_WithEndpoint_ReportsExporting()
{
Bootstrap.ResetForTests();
ObservabilitySdk.ResetForTests();

var stderr = new StringWriter();
var originalError = Console.Error;
Console.SetError(stderr);
try
{
var result = await Bootstrap.Run(new BootstrapEnv
{
Endpoint = "https://collector.example.test",
Token = "pre-minted",
ServiceName = "svc",
});

Assert.True(result.Installed);
Assert.True(result.Exporting);
Assert.NotNull(result.Otel);
Assert.DoesNotContain("NO OTLP ENDPOINT CONFIGURED", stderr.ToString(), StringComparison.Ordinal);
}
finally
{
Console.SetError(originalError);
Bootstrap.ResetForTests();
ObservabilitySdk.ResetForTests();
}
}

/// <summary>Clears env vars for the scope of a test and restores them after.</summary>
private sealed class ScopedEnv : IDisposable
{
private readonly Dictionary<string, string?> _saved = new(StringComparer.Ordinal);

public ScopedEnv(params string[] keys)
{
foreach (var key in keys)
{
_saved[key] = Environment.GetEnvironmentVariable(key);
Environment.SetEnvironmentVariable(key, null);
}
}

public void Dispose()
{
foreach (var (key, value) in _saved)
{
Environment.SetEnvironmentVariable(key, value);
}
}
}
}

[CollectionDefinition("Bootstrap", DisableParallelization = true)]
public class BootstrapCollection { }
/// <summary>
/// Serializes every test class that touches the process-wide
/// <c>ObservabilitySdk</c> install guard.
///
/// <para>
/// xUnit parallelizes ACROSS collections, so a class outside this one runs
/// concurrently with it — and since <c>ObservabilitySdk.ResetForTests()</c> wipes
/// a static singleton, a concurrent reset lands between another test's two
/// <c>Setup()</c> calls and its idempotency assertion fails. That was a real
/// 3-in-8 flake on <c>OtelSetupTests.Setup_IsIdempotent</c>, reproduced on a
/// clean tree before this attribute was applied. Any new class that calls
/// <c>ObservabilitySdk.ResetForTests()</c> or <c>Bootstrap.ResetForTests()</c>
/// must join this collection.
/// </para>
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public class OtelGlobalStateCollection
{
/// <summary>Collection name — referenced by every member class.</summary>
public const string Name = "OtelGlobalState";
}
24 changes: 12 additions & 12 deletions dotnet/tests/SmooAI.Observability.Tests/CrashChild.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,20 @@ internal static int Run(string[] args)
throw new InvalidOperationException(MainThrowMarker);

case "activity-throw":
{
var source = new ActivitySource(OtelSdkHandle.ActivitySourceName);
// Deliberately NOT in a `using`: an unhandled exception terminates
// without unwinding, so a `using` here would be a lie about what
// happens at crash time — and the whole point is that the SDK has
// to stop this activity itself or it never reaches the exporter.
var activity = source.StartActivity(ActivityName, ActivityKind.Internal);
if (activity is null)
{
Console.Error.WriteLine("NO-ACTIVITY-LISTENER");
return 4;
var source = new ActivitySource(OtelSdkHandle.ActivitySourceName);
// Deliberately NOT in a `using`: an unhandled exception terminates
// without unwinding, so a `using` here would be a lie about what
// happens at crash time — and the whole point is that the SDK has
// to stop this activity itself or it never reaches the exporter.
var activity = source.StartActivity(ActivityName, ActivityKind.Internal);
if (activity is null)
{
Console.Error.WriteLine("NO-ACTIVITY-LISTENER");
return 4;
}
throw new InvalidOperationException(ActivityThrowMarker);
}
throw new InvalidOperationException(ActivityThrowMarker);
}

case "unobserved-task":
CreateFaultedTaskAndDropIt();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace SmooAI.Observability.Tests;
/// is subscribed to it, so it's a reliable proxy for "the instrumentation
/// registered its source".
/// </summary>
[Collection(OtelGlobalStateCollection.Name)]
public class OtelInstrumentationTests
{
// Source names the OTel instrumentation packages subscribe to. Stable across
Expand Down
1 change: 1 addition & 0 deletions dotnet/tests/SmooAI.Observability.Tests/OtelSetupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SmooAI.Observability.Tests;

[Collection(OtelGlobalStateCollection.Name)]
public class OtelSetupTests
{
[Fact]
Expand Down
31 changes: 30 additions & 1 deletion go/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,17 @@ import (
// BootstrapResult reports what the bootstrap did.
type BootstrapResult struct {
// Installed is false when disabled or already bootstrapped.
//
// NOTE: Installed == true only means bootstrap ran. It does NOT mean
// anything is being exported — check Exporting for that. These were the
// same flag until 2026-08-15, and the conflation hid a production service
// emitting nothing for months: it had no endpoint configured, so no OTLP
// exporter was ever built, yet bootstrap reported success.
Installed bool
// Exporting reports whether an OTLP exporter was actually installed — i.e.
// whether spans, metrics and logs have somewhere to go. False when no
// endpoint is configured, in which case this SDK is a no-op for telemetry.
Exporting bool
// Otel is the SDK handle (nil if init failed or was skipped).
Otel *OtelSDKHandle
}
Expand Down Expand Up @@ -122,6 +132,19 @@ func Bootstrap(ctx context.Context, overrides *BootstrapEnv) BootstrapResult {
logEndpoint = strings.TrimRight(env.Endpoint, "/") + "/v1/logs"
}

// The single most expensive silence this SDK can produce. With no endpoint
// nothing reaches a collector, yet every other signal (this result, the
// caller's own "observability enabled" log) still says healthy. Say it
// plainly and name the variable to set. SetupOtelSDK also honours the
// generic OTEL_EXPORTER_OTLP_ENDPOINT, so that counts as configured too.
if traceEndpoint == "" && metricEndpoint == "" && logEndpoint == "" && os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" {
warn("NO OTLP ENDPOINT CONFIGURED — telemetry is NOT being exported. " +
"Nothing this process emits will reach a collector. " +
"Set SMOOAI_OBSERVABILITY_ENDPOINT (or a per-signal " +
"OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT), or set " +
"SMOOAI_OBSERVABILITY_DISABLED=true to make this silence deliberate.")
}

otelHandle := SetupOtelSDK(ctx, SetupOtelOptions{
ServiceName: env.ServiceName,
Environment: env.Environment,
Expand Down Expand Up @@ -153,7 +176,13 @@ func Bootstrap(ctx context.Context, overrides *BootstrapEnv) BootstrapResult {
})
}

result = BootstrapResult{Installed: true, Otel: otelHandle}
// Truth comes from the handle, not from the endpoint strings: an endpoint
// whose exporter failed to construct leaves every provider nil, and that is
// just as much "not exporting" as having no endpoint at all.
exporting := otelHandle != nil &&
(otelHandle.TracerProvider != nil || otelHandle.MeterProvider != nil || otelHandle.LoggerProvider != nil)

result = BootstrapResult{Installed: true, Exporting: exporting, Otel: otelHandle}
bootstrapResult = &result
return result
}
Expand Down
Loading
Loading