diff --git a/.changeset/bootstrap-honest-status-polyglot.md b/.changeset/bootstrap-honest-status-polyglot.md new file mode 100644 index 0000000..ec14f0e --- /dev/null +++ b/.changeset/bootstrap-honest-status-polyglot.md @@ -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. diff --git a/dotnet/src/SmooAI.Observability/Bootstrap.cs b/dotnet/src/SmooAI.Observability/Bootstrap.cs index 14f3669..68d7ea0 100644 --- a/dotnet/src/SmooAI.Observability/Bootstrap.cs +++ b/dotnet/src/SmooAI.Observability/Bootstrap.cs @@ -54,9 +54,25 @@ public sealed class BootstrapEnv /// public sealed class BootstrapResult { - /// Whether the bootstrap actually ran. + /// + /// Whether the bootstrap actually ran (false = disabled or init threw). + /// + /// NOTE: Installed == true only means bootstrap ran. It does NOT mean + /// anything is being exported — check 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. + /// + /// public bool Installed { get; init; } + /// + /// 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. + /// + public bool Exporting { get; init; } + /// OTel handle (flush/shutdown). Null if init failed or was skipped. public OtelSdkHandle? Otel { get; init; } } @@ -95,7 +111,7 @@ public static async Task 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 @@ -118,6 +134,26 @@ public static async Task 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", @@ -146,12 +182,12 @@ public static async Task 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 }); } } diff --git a/dotnet/tests/SmooAI.Observability.Tests/BootstrapTests.cs b/dotnet/tests/SmooAI.Observability.Tests/BootstrapTests.cs index 3182e45..100c7bd 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/BootstrapTests.cs +++ b/dotnet/tests/SmooAI.Observability.Tests/BootstrapTests.cs @@ -3,7 +3,7 @@ namespace SmooAI.Observability.Tests; -[Collection("Bootstrap")] +[Collection(OtelGlobalStateCollection.Name)] public class BootstrapTests { [Fact] @@ -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(); + } + } + + /// + /// 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. + /// + [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(); + } + } + + /// Clears env vars for the scope of a test and restores them after. + private sealed class ScopedEnv : IDisposable + { + private readonly Dictionary _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 { } +/// +/// Serializes every test class that touches the process-wide +/// ObservabilitySdk install guard. +/// +/// +/// xUnit parallelizes ACROSS collections, so a class outside this one runs +/// concurrently with it — and since ObservabilitySdk.ResetForTests() wipes +/// a static singleton, a concurrent reset lands between another test's two +/// Setup() calls and its idempotency assertion fails. That was a real +/// 3-in-8 flake on OtelSetupTests.Setup_IsIdempotent, reproduced on a +/// clean tree before this attribute was applied. Any new class that calls +/// ObservabilitySdk.ResetForTests() or Bootstrap.ResetForTests() +/// must join this collection. +/// +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class OtelGlobalStateCollection +{ + /// Collection name — referenced by every member class. + public const string Name = "OtelGlobalState"; +} diff --git a/dotnet/tests/SmooAI.Observability.Tests/CrashChild.cs b/dotnet/tests/SmooAI.Observability.Tests/CrashChild.cs index 5279132..8b039e1 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/CrashChild.cs +++ b/dotnet/tests/SmooAI.Observability.Tests/CrashChild.cs @@ -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(); diff --git a/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs b/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs index 1752a5e..ea094dc 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs +++ b/dotnet/tests/SmooAI.Observability.Tests/OtelInstrumentationTests.cs @@ -11,6 +11,7 @@ namespace SmooAI.Observability.Tests; /// is subscribed to it, so it's a reliable proxy for "the instrumentation /// registered its source". /// +[Collection(OtelGlobalStateCollection.Name)] public class OtelInstrumentationTests { // Source names the OTel instrumentation packages subscribe to. Stable across diff --git a/dotnet/tests/SmooAI.Observability.Tests/OtelSetupTests.cs b/dotnet/tests/SmooAI.Observability.Tests/OtelSetupTests.cs index dc54d34..85da433 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/OtelSetupTests.cs +++ b/dotnet/tests/SmooAI.Observability.Tests/OtelSetupTests.cs @@ -2,6 +2,7 @@ namespace SmooAI.Observability.Tests; +[Collection(OtelGlobalStateCollection.Name)] public class OtelSetupTests { [Fact] diff --git a/go/bootstrap.go b/go/bootstrap.go index 3a7aa6a..421dbbf 100644 --- a/go/bootstrap.go +++ b/go/bootstrap.go @@ -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 } @@ -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, @@ -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 } diff --git a/go/bootstrap_test.go b/go/bootstrap_test.go index 7067890..6d42161 100644 --- a/go/bootstrap_test.go +++ b/go/bootstrap_test.go @@ -2,9 +2,35 @@ package observability import ( "context" + "io" + "os" + "strings" "testing" ) +// captureStderr swaps os.Stderr for a pipe for the duration of fn and returns +// what was written. warn() resolves os.Stderr at call time, so this sees it. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + original := os.Stderr + os.Stderr = w + defer func() { os.Stderr = original }() + + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + _ = w.Close() + return <-done +} + func TestBootstrapDisabled(t *testing.T) { resetBootstrap() defer resetBootstrap() @@ -33,16 +59,44 @@ func TestBootstrapInstallsClientAndCapture(t *testing.T) { defer resetBootstrap() defer resetOtelSDK() + // SetupOtelSDK falls back to these env vars, so "no endpoint" has to mean + // no endpoint from ANY source or the Exporting assertion below is + // environment-dependent. t.Setenv restores them after the test. + for _, key := range []string{ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "SMOOAI_OBSERVABILITY_ENDPOINT", + } { + t.Setenv(key, "") + } + // No endpoint / no auth — bootstrap should still install the Client and the // OTel-native capture path without panicking. - res := Bootstrap(context.Background(), &BootstrapEnv{ - ServiceName: "svc", - Environment: "test", - Release: "r1", + var res BootstrapResult + stderr := captureStderr(t, func() { + res = Bootstrap(context.Background(), &BootstrapEnv{ + ServiceName: "svc", + Environment: "test", + Release: "r1", + }) }) + if !strings.Contains(stderr, "NO OTLP ENDPOINT CONFIGURED") { + t.Errorf("no-endpoint bootstrap must warn loudly; stderr was: %q", stderr) + } + if !strings.Contains(stderr, "SMOOAI_OBSERVABILITY_DISABLED=true") { + t.Errorf("warning must offer the explicit-disable var; stderr was: %q", stderr) + } if !res.Installed { t.Fatal("bootstrap did not install") } + // …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. + if res.Exporting { + t.Error("no endpoint configured must report Exporting = false") + } if !Default.IsInitialized() { t.Error("default client not initialized by bootstrap") } @@ -79,3 +133,28 @@ func TestBootstrapWiresWebhookTransportWhenDSN(t *testing.T) { t.Error("webhook transport not wired despite DSN") } } + +// 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. +func TestBootstrapExportingWhenEndpointConfigured(t *testing.T) { + resetBootstrap() + resetOtelSDK() + defer resetBootstrap() + defer resetOtelSDK() + + res := Bootstrap(context.Background(), &BootstrapEnv{ + ServiceName: "svc", + Endpoint: "https://collector.example.com", + Token: "pre-minted", + }) + if !res.Installed { + t.Fatal("bootstrap did not install") + } + if !res.Exporting { + t.Error("an endpoint was configured, so Exporting must be true") + } + if res.Otel == nil || res.Otel.TracerProvider == nil { + t.Error("expected a tracer provider when an endpoint is configured") + } +} diff --git a/packages/core/src/__tests__/bootstrap.test.ts b/packages/core/src/__tests__/bootstrap.test.ts index 094848f..62e8f3d 100644 --- a/packages/core/src/__tests__/bootstrap.test.ts +++ b/packages/core/src/__tests__/bootstrap.test.ts @@ -85,6 +85,49 @@ describe('bootstrapObservability', () => { } }); + // --- honest export status ------------------------------------------- + // Both halves matter. With only one asserted, an implementation that + // hard-codes either value passes. + + it('reports exporting=false and warns loudly when no endpoint is configured', async () => { + const saved = { + base: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + traces: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + metrics: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + logs: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + }; + // A previous bootstrap in this process may have set these — the + // function writes them and never clears them. + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT; + try { + const result = await bootstrapObservability({ token: 't', serviceName: 'svc' }); + expect(result.installed).toBe(true); // bootstrap ran… + expect(result.exporting).toBe(false); // …but nothing has anywhere to go + expect(stderr.join('')).toContain('NO OTLP ENDPOINT CONFIGURED'); + expect(stderr.join('')).toContain('SMOOAI_OBSERVABILITY_DISABLED=true'); + } finally { + for (const [key, value] of [ + ['OTEL_EXPORTER_OTLP_ENDPOINT', saved.base], + ['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', saved.traces], + ['OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', saved.metrics], + ['OTEL_EXPORTER_OTLP_LOGS_ENDPOINT', saved.logs], + ] as [string, string | undefined][]) { + if (value) process.env[key] = value; + else delete process.env[key]; + } + } + }); + + it('reports exporting=true and stays quiet when an endpoint IS configured', async () => { + const result = await bootstrapObservability({ token: 't', endpoint: 'https://api.test' }); + expect(result.installed).toBe(true); + expect(result.exporting).toBe(true); + expect(stderr.join('')).not.toContain('NO OTLP ENDPOINT CONFIGURED'); + }); + it('does not crash the host when SDK init throws — returns installed=false', async () => { // Force a bad endpoint that surfaces during exporter validation in // some otel-js versions. We can't reliably force a throw in the SDK diff --git a/packages/core/src/bootstrap/index.ts b/packages/core/src/bootstrap/index.ts index 1770a7b..9844ae2 100644 --- a/packages/core/src/bootstrap/index.ts +++ b/packages/core/src/bootstrap/index.ts @@ -78,8 +78,26 @@ import { setupOtelSdk, type OtelSdkHandle, type SetupOtelOptions } from '../otel const TOKEN_REFRESH_INTERVAL_MS = 55 * 60 * 1000; // < openauth's 1h JWT TTL export interface BootstrapResult { - /** Whether the bootstrap actually ran (false = disabled or already-installed). */ + /** + * Whether the bootstrap actually ran (false = disabled, already-installed, + * or init threw). + * + * NOTE: `installed: true` only means bootstrap was not disabled and did not + * throw. It does NOT mean anything is being exported — check + * {@link BootstrapResult.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 destination + * was ever resolved, yet bootstrap reported success. + */ installed: boolean; + /** + * Whether an OTLP endpoint was actually configured — i.e. whether spans, + * metrics and logs have somewhere to go. False when no endpoint resolves, + * in which case this SDK is a no-op for telemetry: the OTel exporters fall + * back to `http://localhost:4318`, which in a container drops everything + * silently and forever. + */ + exporting: boolean; /** OTel SDK handle — flush / shutdown hooks. `null` if init failed or was skipped. */ otel: OtelSdkHandle | null; /** Stops the background token-refresh timer. No-op if no timer was armed. */ @@ -119,7 +137,7 @@ export async function bootstrapObservability(overrides: Partial = }; if (env.disabled) { - bootstrapped = { installed: false, otel: null, stopRefresh: () => {} }; + bootstrapped = { installed: false, exporting: false, otel: null, stopRefresh: () => {} }; return bootstrapped; } @@ -167,6 +185,23 @@ export async function bootstrapObservability(overrides: Partial = const metricsEndpoint = env.metricsEndpoint ?? (env.endpoint ? `${stripTrailingSlash(env.endpoint)}/v1/metrics` : undefined); const logsEndpoint = env.logsEndpoint ?? (env.endpoint ? `${stripTrailingSlash(env.endpoint)}/v1/logs` : undefined); + // The single most expensive silence this SDK can produce. With no + // endpoint, nothing reaches a collector — but every other signal (the + // bootstrap return, the caller's own "observability enabled" log) still + // says healthy. Say it plainly instead, and name the variable to set. + // `setupOtelSdk` also honours the generic OTEL_EXPORTER_OTLP_ENDPOINT, + // so a destination configured that way still counts as exporting. + const exporting = Boolean(tracesEndpoint || metricsEndpoint || logsEndpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT); + 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.', + ); + } + // Set process.env so any *other* OTel-aware code in the process // (e.g. third-party libraries that read the env directly) sees the // same endpoints. setupOtelSdk reads env too, so this also covers @@ -199,11 +234,11 @@ export async function bootstrapObservability(overrides: Partial = release: env.release, }); - bootstrapped = { installed: true, otel, stopRefresh }; + bootstrapped = { installed: true, exporting, otel, stopRefresh }; } catch (err) { warn(`bootstrap: SDK init failed: ${err instanceof Error ? err.message : String(err)}`); stopRefresh(); - bootstrapped = { installed: false, otel: null, stopRefresh: () => {} }; + bootstrapped = { installed: false, exporting: false, otel: null, stopRefresh: () => {} }; } return bootstrapped; diff --git a/python/src/smooai_observability/bootstrap/__init__.py b/python/src/smooai_observability/bootstrap/__init__.py index a1be0a4..45a2f98 100644 --- a/python/src/smooai_observability/bootstrap/__init__.py +++ b/python/src/smooai_observability/bootstrap/__init__.py @@ -58,7 +58,23 @@ class BootstrapEnv: @dataclass class BootstrapResult: + """What the bootstrap did. + + ``installed`` only means bootstrap ran (it was not disabled and did not + raise). It does NOT mean anything is being exported — that is ``exporting``. + 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 destination was ever resolved, yet bootstrap reported + success. + """ + installed: bool + #: Whether an OTLP endpoint was actually configured — i.e. whether spans, + #: metrics and logs have somewhere to go. False when no endpoint resolves, + #: in which case this SDK is a no-op for telemetry: the OTel exporters fall + #: back to ``http://localhost:4318``, which in a container drops everything + #: silently and forever. + exporting: bool = False otel: object = None # OtelSdkHandle | None transport: Transport | None = None @@ -143,6 +159,21 @@ def bootstrap_observability( metrics_endpoint = env.metrics_endpoint or (f"{_strip_trailing_slash(env.endpoint)}/v1/metrics" if env.endpoint else None) logs_endpoint = env.logs_endpoint or (f"{_strip_trailing_slash(env.endpoint)}/v1/logs" if env.endpoint else None) + # 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. setup_otel_sdk + # also honours the generic OTEL_EXPORTER_OTLP_ENDPOINT. + endpoint_configured = bool(traces_endpoint or metrics_endpoint or logs_endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")) + if not endpoint_configured: + _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." + ) + otel_handle = _maybe_setup_otel( service_name=env.service_name, environment=env.environment, @@ -180,7 +211,11 @@ def _send(batch: list) -> None: # exception reports nothing — the batched exporters go down with their queues full. _install_crash_handler(transport) - _bootstrapped = BootstrapResult(installed=True, otel=otel_handle, transport=transport) + # An endpoint whose provider setup failed (missing otlp extra, bad URL) + # is just as much "not exporting" as having no endpoint at all. + exporting = endpoint_configured and bool(getattr(otel_handle, "enabled", False)) + + _bootstrapped = BootstrapResult(installed=True, exporting=exporting, otel=otel_handle, transport=transport) except Exception as err: _warn(f"SDK init failed: {err}") _bootstrapped = BootstrapResult(installed=False) diff --git a/python/tests/test_bootstrap.py b/python/tests/test_bootstrap.py index 1dfbfaa..363a139 100644 --- a/python/tests/test_bootstrap.py +++ b/python/tests/test_bootstrap.py @@ -54,11 +54,46 @@ def test_installs_and_inits_client_with_static_token(): _reset() -def test_never_raises_on_bad_config(): +def test_never_raises_on_bad_config(monkeypatch, capsys): _reset() try: + # setup_otel_sdk falls back to these, so "no endpoint" has to mean no + # endpoint from ANY source or the exporting assertion below would be + # environment-dependent. + for key in ( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "SMOOAI_OBSERVABILITY_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + # No auth, no endpoint — must still return a result, not raise. result = bootstrap_observability(BootstrapEnv(), fetch_token=False) + assert result.installed is True # 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 result.exporting is False + stderr = capsys.readouterr().err + assert "NO OTLP ENDPOINT CONFIGURED" in stderr + assert "SMOOAI_OBSERVABILITY_DISABLED=true" in stderr + finally: + _reset() + + +def test_exporting_true_when_endpoint_configured(capsys): + """The inverse of the no-endpoint case. Without both halves asserted, a + regression that hard-codes either value passes.""" + _reset() + try: + result = bootstrap_observability( + BootstrapEnv(endpoint="https://collector.example.test", token="pre-minted", service_name="svc"), + fetch_token=False, + ) assert result.installed is True + assert result.exporting is True + assert "NO OTLP ENDPOINT CONFIGURED" not in capsys.readouterr().err finally: _reset()