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()