From 8b6c6d616d08fe457daaeb899890723978f6034c Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 15 Aug 2026 15:32:24 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Stop=20reporting=20success=20when=20the=20S?= =?UTF-8?q?DK=20is=20exporting=20nothing=20=E2=80=94=20TS,=20Go,=20Python,?= =?UTF-8?q?=20.NET?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Rust fix from #84 to the other four SDKs. Every one of them had the same bug: `bootstrap()` reported `installed: true` whenever it was not explicitly disabled — INCLUDING when no OTLP endpoint was configured, in which case telemetry has nowhere to go. The no-endpoint branch was completely silent; it warned about missing AUTH but not about the far more consequential missing DESTINATION. TypeScript and Python are worse than Rust was. Rust at least skipped building the SDK with no endpoint. TS and Python construct an OTLP exporter with NO url, which the OTel default sends to `http://localhost:4318` — so a container with no endpoint configured doesn't no-op, it retries into the void forever. The Python test conftest already silences the resulting "connection refused to localhost:4318" spam, which is the bug leaving a note about itself. Per language: 1. An honest status flag alongside the existing one — `exporting` (TS, Python), `Exporting` (Go, .NET), matching Rust's `exporting`. `installed`/`Installed` keeps its old meaning and now says so honestly in its doc comment. Go derives it from the handle rather than 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. .NET counts traces + metrics only — `Setup` builds exporters for exactly those two, and a LogsEndpoint alone is consumed by the ILoggingBuilder extension, so claiming Exporting on it would be the same lie in a new place. 2. A loud warning when no endpoint is configured, wording matched to Rust's: it names the variable to set AND offers SMOOAI_OBSERVABILITY_DISABLED=true so an intentional no-op can be declared rather than inferred from absence. 3. Both halves tested in all four — no endpoint ⇒ flag false, endpoint set ⇒ flag true. With only one asserted, an implementation that hard-codes either value passes; each assertion was mutation-checked to confirm it fails on its own. Three tests enshrined the misleading shape and now assert the honest one: Go's TestBootstrapInstallsClientAndCapture, Python's test_never_raises_on_bad_config, and .NET's Run_NeverThrows_OnBadConfig all asserted `installed` while nothing had a destination. Each also pins the warning now, and each clears the OTEL_EXPORTER_OTLP_* env vars so "no endpoint" means no endpoint from any source rather than whatever the CI runner happens to export. Also renames the .NET xUnit collection to OtelGlobalStateCollection — see the next commit for why it had to grow members. Gates, by exit code: TS typecheck/lint/test/build/format:check 0 (263 tests); Go gofmt/vet/test 0 across all three modules; ruff check + format --check 0, pytest 0 (78 tests); dotnet build/test/format 0 (80 tests). Co-Authored-By: Claude Opus 5 (1M context) --- .../bootstrap-honest-status-polyglot.md | 13 ++ dotnet/src/SmooAI.Observability/Bootstrap.cs | 44 ++++++- .../BootstrapTests.cs | 116 +++++++++++++++++- go/bootstrap.go | 31 ++++- go/bootstrap_test.go | 87 ++++++++++++- packages/core/src/__tests__/bootstrap.test.ts | 43 +++++++ packages/core/src/bootstrap/index.ts | 43 ++++++- .../bootstrap/__init__.py | 37 +++++- python/tests/test_bootstrap.py | 37 +++++- 9 files changed, 430 insertions(+), 21 deletions(-) create mode 100644 .changeset/bootstrap-honest-status-polyglot.md 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/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() From fafaba907fd0fab7c9a5e379aca7a5781569f515 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 15 Aug 2026 15:32:37 -0400 Subject: [PATCH 2/2] dotnet: unbreak the two CI gates this PR's lane would have failed on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are pre-existing on origin/main and unrelated to the bootstrap change — but the dotnet lane only runs when dotnet/** changes, so this is the PR that has to face them. 1. `dotnet format --verify-no-changes` exits 2 on CrashChild.cs: 14 WHITESPACE errors, a braced switch-case body indented one level short. Verified identical on a stashed clean tree. Fixed by running `dotnet format` on that one file — pure indentation, no behavior. 2. OtelSetupTests.Setup_IsIdempotent is a flake, and a nasty one: it failed 3 of 8 full-suite runs on a clean tree (it passes 6 of 6 when the suite is filtered down, which is why it hid). ObservabilitySdk._installed is a process-wide static and three classes call ResetForTests() on it, but only BootstrapTests was in a collection. xUnit parallelizes ACROSS collections, so the other two ran concurrently with it and a foreign reset landed between that test's two Setup() calls, wiping the install guard the test exists to assert. Fixed by putting all three classes in one non-parallel collection (OtelGlobalStateCollection, renamed from "Bootstrap" since it guards the OTel singleton, not bootstrap). 10 of 10 full-suite runs green afterwards, verified by exit code. Co-Authored-By: Claude Opus 5 (1M context) --- .../SmooAI.Observability.Tests/CrashChild.cs | 24 +++++++++---------- .../OtelInstrumentationTests.cs | 1 + .../OtelSetupTests.cs | 1 + 3 files changed, 14 insertions(+), 12 deletions(-) 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]