From baa3c5ae1fea3619fdc0d7b26c6e25c248c25452 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 15 Jul 2026 14:01:37 +0800 Subject: [PATCH 1/7] fix(cli): bound posthog close, quiet sdk logs --- apps/cli-go/internal/telemetry/client.go | 14 +++++++++++++- apps/cli-go/internal/telemetry/client_test.go | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/cli-go/internal/telemetry/client.go b/apps/cli-go/internal/telemetry/client.go index 34b529a3b3..c7064531fa 100644 --- a/apps/cli-go/internal/telemetry/client.go +++ b/apps/cli-go/internal/telemetry/client.go @@ -1,11 +1,14 @@ package telemetry import ( + "log" "net/http" "strings" + "time" "github.com/go-errors/errors" "github.com/posthog/posthog-go" + "github.com/supabase/cli/internal/utils" ) type Analytics interface { @@ -24,6 +27,8 @@ type queueClient interface { type constructor func(apiKey string, config posthog.Config) (queueClient, error) +const shutdownTimeout = 5 * time.Second + type Client struct { client queueClient baseProperties posthog.Properties @@ -38,7 +43,14 @@ func NewClient(apiKey string, endpoint string, baseProperties map[string]any, fa return posthog.NewWithConfig(apiKey, config) } } - config := posthog.Config{} + // Without a positive ShutdownTimeout, Close blocks indefinitely when the + // endpoint is unreachable, hanging every command on networks that block + // PostHog. The default logger prints delivery failures to stderr even for + // commands that succeeded, so route them to the --debug logger instead. + config := posthog.Config{ + ShutdownTimeout: shutdownTimeout, + Logger: posthog.StdLogger(log.New(utils.GetDebugLogger(), "posthog ", log.LstdFlags), true), + } if endpoint != "" { config.Endpoint = endpoint } diff --git a/apps/cli-go/internal/telemetry/client_test.go b/apps/cli-go/internal/telemetry/client_test.go index 6ee8ccb27a..e1711a3443 100644 --- a/apps/cli-go/internal/telemetry/client_test.go +++ b/apps/cli-go/internal/telemetry/client_test.go @@ -3,6 +3,7 @@ package telemetry import ( "net/http" "testing" + "time" "github.com/posthog/posthog-go" "github.com/stretchr/testify/assert" @@ -40,6 +41,8 @@ func TestNewClient(t *testing.T) { assert.True(t, client.Enabled()) assert.Equal(t, "phc_test", gotKey) assert.Equal(t, "https://eu.i.posthog.com", gotConfig.Endpoint) + assert.Equal(t, 5*time.Second, gotConfig.ShutdownTimeout) + assert.NotNil(t, gotConfig.Logger) }) t.Run("becomes a no-op when key is empty", func(t *testing.T) { From a96c6f582690ebf2ad0f4e2d760076088fbd18d0 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 15 Jul 2026 14:01:37 +0800 Subject: [PATCH 2/7] fix(cli): fire-and-forget posthog fetch in ts cli --- .../telemetry/legacy-analytics.layer.ts | 11 +---- .../src/shared/telemetry/analytics.layer.ts | 12 ++--- .../src/shared/telemetry/posthog-client.ts | 44 +++++++++++++++++++ .../telemetry/posthog-client.unit.test.ts | 37 ++++++++++++++++ 4 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 apps/cli/src/shared/telemetry/posthog-client.ts create mode 100644 apps/cli/src/shared/telemetry/posthog-client.unit.test.ts diff --git a/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts b/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts index c09fc962b1..1a9ccabeaf 100644 --- a/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts @@ -1,5 +1,4 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; -import { PostHog } from "posthog-node"; import { aiToolLayer } from "../../shared/telemetry/ai-tool.layer.ts"; import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; import { @@ -26,6 +25,7 @@ import { PropSchemaVersion, PropSessionId, } from "../../shared/telemetry/event-catalog.ts"; +import { scopedPosthogClient } from "../../shared/telemetry/posthog-client.ts"; import { resolvePosthogConfig } from "../../shared/telemetry/posthog-config.ts"; import { telemetryRuntimeLayer } from "../../shared/telemetry/runtime.layer.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; @@ -158,14 +158,7 @@ export const legacyAnalyticsLayer = Layer.effect( }); } - const client = new PostHog(posthogConfig.key.value, { - host: posthogConfig.host, - flushAt: 1, - flushInterval: 0, - }); - yield* Effect.addFinalizer(() => - Effect.promise(() => client._shutdown(5_000)).pipe(Effect.ignore), - ); + const client = yield* scopedPosthogClient(posthogConfig.key.value, posthogConfig.host); const loadLinkedProject = makeLoadLinkedProject(fs, path); diff --git a/apps/cli/src/shared/telemetry/analytics.layer.ts b/apps/cli/src/shared/telemetry/analytics.layer.ts index 66a3a8e685..e25f983d7f 100644 --- a/apps/cli/src/shared/telemetry/analytics.layer.ts +++ b/apps/cli/src/shared/telemetry/analytics.layer.ts @@ -1,4 +1,3 @@ -import { PostHog } from "posthog-node"; import { Effect, Layer, Option } from "effect"; import type { ProjectLinkStateValue } from "../../next/config/project-link-state.service.ts"; import { ProjectLinkState } from "../../next/config/project-link-state.service.ts"; @@ -7,6 +6,7 @@ import { aiToolLayer } from "./ai-tool.layer.ts"; import { CurrentAnalyticsContext, type AnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; import { AiTool } from "./ai-tool.service.ts"; +import { scopedPosthogClient } from "./posthog-client.ts"; import { telemetryRuntimeLayer } from "./runtime.layer.ts"; import { TelemetryRuntime } from "./runtime.service.ts"; @@ -59,13 +59,9 @@ export const analyticsLayer = Layer.effect( }); } - const client = new PostHog(cliConfig.telemetryPosthogKey.value, { - host: cliConfig.telemetryPosthogHost, - flushAt: 1, - flushInterval: 0, - }); - yield* Effect.addFinalizer(() => - Effect.promise(() => client._shutdown(5_000)).pipe(Effect.ignore), + const client = yield* scopedPosthogClient( + cliConfig.telemetryPosthogKey.value, + cliConfig.telemetryPosthogHost, ); const baseProperties = stripUndefined({ diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts new file mode 100644 index 0000000000..c0ca1a7b91 --- /dev/null +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect"; +import { PostHog, type PostHogOptions } from "posthog-node"; + +const SHUTDOWN_TIMEOUT_MS = 5_000; + +const droppedDelivery = { + status: 200, + text: () => Promise.resolve(""), + json: () => Promise.resolve({}), +}; + +// posthog-node has no logger hook: delivery failures reach the user's +// terminal through hardcoded console.error calls inside the SDK, and failed +// batches are retried with multi-second delays that stall process shutdown. +// Reporting every attempt as delivered keeps telemetry fire-and-forget — a +// blocked or offline network must never surface errors or delays for a +// command that succeeded. +export function fireAndForgetFetch( + fetchImpl: (url: string, init?: RequestInit) => Promise = globalThis.fetch, +): NonNullable { + return async (url, options) => { + try { + const response = await fetchImpl(url, options); + return response.status >= 400 ? droppedDelivery : response; + } catch { + return droppedDelivery; + } + }; +} + +export function scopedPosthogClient(apiKey: string, host: string) { + return Effect.acquireRelease( + Effect.sync( + () => + new PostHog(apiKey, { + host, + flushAt: 1, + flushInterval: 0, + fetch: fireAndForgetFetch(), + }), + ), + (client) => Effect.promise(() => client._shutdown(SHUTDOWN_TIMEOUT_MS)).pipe(Effect.ignore), + ); +} diff --git a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts new file mode 100644 index 0000000000..ec7a8a103a --- /dev/null +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; +import { fireAndForgetFetch } from "./posthog-client.ts"; + +const BATCH_URL = "https://eu.i.posthog.com/batch/"; +const BATCH_OPTIONS = { method: "POST" as const, headers: {}, body: "{}" }; + +describe("fireAndForgetFetch", () => { + it("passes successful responses through untouched", async () => { + const fetchImpl = async () => new Response(`{"status":1}`, { status: 200 }); + + const response = await fireAndForgetFetch(fetchImpl)(BATCH_URL, BATCH_OPTIONS); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(`{"status":1}`); + }); + + it("reports success when the network is unreachable", async () => { + const fetchImpl = async (): Promise => { + throw new Error("connect ECONNREFUSED"); + }; + + const response = await fireAndForgetFetch(fetchImpl)(BATCH_URL, BATCH_OPTIONS); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + expect(await response.json()).toEqual({}); + }); + + it("reports success on error responses so the SDK never retries or logs", async () => { + const fetchImpl = async () => new Response("Proxy Authentication Required", { status: 407 }); + + const response = await fireAndForgetFetch(fetchImpl)(BATCH_URL, BATCH_OPTIONS); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + }); +}); From e38cb95741bf73a89fa5aa1bdb7dd7479b9ec72a Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 15 Jul 2026 14:23:52 +0800 Subject: [PATCH 3/7] test(cli): cover posthog logger routing and scoped client --- apps/cli-go/internal/telemetry/client_test.go | 34 +++++++++++++++++++ .../telemetry/posthog-client.unit.test.ts | 14 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/cli-go/internal/telemetry/client_test.go b/apps/cli-go/internal/telemetry/client_test.go index e1711a3443..72b5cb6508 100644 --- a/apps/cli-go/internal/telemetry/client_test.go +++ b/apps/cli-go/internal/telemetry/client_test.go @@ -1,11 +1,14 @@ package telemetry import ( + "io" "net/http" + "os" "testing" "time" "github.com/posthog/posthog-go" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/debug" @@ -56,6 +59,37 @@ func TestNewClient(t *testing.T) { assert.NoError(t, client.Capture("device-1", EventCommandExecuted, map[string]any{"command": "login"}, nil)) assert.NoError(t, client.Close()) }) + t.Run("routes posthog logs to stderr only in debug mode", func(t *testing.T) { + originalStderr := os.Stderr + originalDebug := viper.GetBool("DEBUG") + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stderr = writer + t.Cleanup(func() { + os.Stderr = originalStderr + viper.Set("DEBUG", originalDebug) + }) + + configFor := func(debugEnabled bool) posthog.Config { + viper.Set("DEBUG", debugEnabled) + var gotConfig posthog.Config + _, err := NewClient("phc_test", "", nil, func(apiKey string, config posthog.Config) (queueClient, error) { + gotConfig = config + return &fakeQueue{}, nil + }) + require.NoError(t, err) + return gotConfig + } + + configFor(false).Logger.Errorf("dropped %d messages", 1) + configFor(true).Logger.Errorf("dropped %d messages", 2) + + require.NoError(t, writer.Close()) + output, err := io.ReadAll(reader) + require.NoError(t, err) + assert.NotContains(t, string(output), "dropped 1 messages") + assert.Contains(t, string(output), "dropped 2 messages") + }) t.Run("works when debug wraps the default transport", func(t *testing.T) { original := http.DefaultTransport http.DefaultTransport = debug.NewTransport() diff --git a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts index ec7a8a103a..dfab1e8e64 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { fireAndForgetFetch } from "./posthog-client.ts"; +import { Effect } from "effect"; +import { PostHog } from "posthog-node"; +import { fireAndForgetFetch, scopedPosthogClient } from "./posthog-client.ts"; const BATCH_URL = "https://eu.i.posthog.com/batch/"; const BATCH_OPTIONS = { method: "POST" as const, headers: {}, body: "{}" }; @@ -35,3 +37,13 @@ describe("fireAndForgetFetch", () => { expect(await response.text()).toBe(""); }); }); + +describe("scopedPosthogClient", () => { + it.live("captures and shuts down cleanly against an unreachable host", () => + Effect.gen(function* () { + const client = yield* scopedPosthogClient("phc_test", "http://127.0.0.1:9"); + expect(client).toBeInstanceOf(PostHog); + client.capture({ event: "verify_event", distinctId: "device-1" }); + }).pipe(Effect.scoped), + ); +}); From c367a3eded683653b158ce846268d3937b58a7d7 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 15 Jul 2026 18:17:04 +0800 Subject: [PATCH 4/7] fix(cli): cap go telemetry close at 2s --- apps/cli-go/internal/telemetry/client.go | 5 +++-- apps/cli-go/internal/telemetry/client_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/internal/telemetry/client.go b/apps/cli-go/internal/telemetry/client.go index c7064531fa..717e4af181 100644 --- a/apps/cli-go/internal/telemetry/client.go +++ b/apps/cli-go/internal/telemetry/client.go @@ -27,7 +27,7 @@ type queueClient interface { type constructor func(apiKey string, config posthog.Config) (queueClient, error) -const shutdownTimeout = 5 * time.Second +const shutdownTimeout = 2 * time.Second type Client struct { client queueClient @@ -45,7 +45,8 @@ func NewClient(apiKey string, endpoint string, baseProperties map[string]any, fa } // Without a positive ShutdownTimeout, Close blocks indefinitely when the // endpoint is unreachable, hanging every command on networks that block - // PostHog. The default logger prints delivery failures to stderr even for + // PostHog; keep it tight because blackholed networks pay the whole timeout + // at exit. The default logger prints delivery failures to stderr even for // commands that succeeded, so route them to the --debug logger instead. config := posthog.Config{ ShutdownTimeout: shutdownTimeout, diff --git a/apps/cli-go/internal/telemetry/client_test.go b/apps/cli-go/internal/telemetry/client_test.go index 72b5cb6508..bcc8db7d24 100644 --- a/apps/cli-go/internal/telemetry/client_test.go +++ b/apps/cli-go/internal/telemetry/client_test.go @@ -44,7 +44,7 @@ func TestNewClient(t *testing.T) { assert.True(t, client.Enabled()) assert.Equal(t, "phc_test", gotKey) assert.Equal(t, "https://eu.i.posthog.com", gotConfig.Endpoint) - assert.Equal(t, 5*time.Second, gotConfig.ShutdownTimeout) + assert.Equal(t, 2*time.Second, gotConfig.ShutdownTimeout) assert.NotNil(t, gotConfig.Logger) }) From 77ce8398eed2713fd4a50906ebe28d05e514a40c Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 15 Jul 2026 18:17:04 +0800 Subject: [PATCH 5/7] fix(cli): 2s telemetry request timeout, flatten factory --- .../src/shared/telemetry/posthog-client.ts | 45 ++++++++----------- .../telemetry/posthog-client.unit.test.ts | 22 ++++++--- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index c0ca1a7b91..add87043d0 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -1,44 +1,37 @@ import { Effect } from "effect"; import { PostHog, type PostHogOptions } from "posthog-node"; -const SHUTDOWN_TIMEOUT_MS = 5_000; - -const droppedDelivery = { +const delivered = { status: 200, text: () => Promise.resolve(""), json: () => Promise.resolve({}), }; -// posthog-node has no logger hook: delivery failures reach the user's -// terminal through hardcoded console.error calls inside the SDK, and failed -// batches are retried with multi-second delays that stall process shutdown. -// Reporting every attempt as delivered keeps telemetry fire-and-forget — a -// blocked or offline network must never surface errors or delays for a -// command that succeeded. -export function fireAndForgetFetch( - fetchImpl: (url: string, init?: RequestInit) => Promise = globalThis.fetch, -): NonNullable { - return async (url, options) => { - try { - const response = await fetchImpl(url, options); - return response.status >= 400 ? droppedDelivery : response; - } catch { - return droppedDelivery; - } - }; -} +// posthog-node prints delivery failures through hardcoded console.error calls +// (no logger hook) and retries them with multi-second delays; reporting every +// attempt as delivered keeps failures silent and off the critical path. The +// 2s requestTimeout bounds how long a blackholed connection can hold process +// exit, since shutdown awaits in-flight sends. +export const fireAndForgetFetch: NonNullable = async (url, options) => { + try { + const response = await globalThis.fetch(url, options); + return response.status >= 400 ? delivered : response; + } catch { + return delivered; + } +}; -export function scopedPosthogClient(apiKey: string, host: string) { - return Effect.acquireRelease( +export const scopedPosthogClient = (apiKey: string, host: string) => + Effect.acquireRelease( Effect.sync( () => new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0, - fetch: fireAndForgetFetch(), + requestTimeout: 2_000, + fetch: fireAndForgetFetch, }), ), - (client) => Effect.promise(() => client._shutdown(SHUTDOWN_TIMEOUT_MS)).pipe(Effect.ignore), + (client) => Effect.promise(() => client._shutdown(5_000)).pipe(Effect.ignore), ); -} diff --git a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts index dfab1e8e64..33630b234f 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { Effect } from "effect"; import { PostHog } from "posthog-node"; import { fireAndForgetFetch, scopedPosthogClient } from "./posthog-client.ts"; @@ -7,21 +8,25 @@ const BATCH_URL = "https://eu.i.posthog.com/batch/"; const BATCH_OPTIONS = { method: "POST" as const, headers: {}, body: "{}" }; describe("fireAndForgetFetch", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("passes successful responses through untouched", async () => { - const fetchImpl = async () => new Response(`{"status":1}`, { status: 200 }); + vi.stubGlobal("fetch", async () => new Response(`{"status":1}`, { status: 200 })); - const response = await fireAndForgetFetch(fetchImpl)(BATCH_URL, BATCH_OPTIONS); + const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); expect(response.status).toBe(200); expect(await response.text()).toBe(`{"status":1}`); }); it("reports success when the network is unreachable", async () => { - const fetchImpl = async (): Promise => { + vi.stubGlobal("fetch", async () => { throw new Error("connect ECONNREFUSED"); - }; + }); - const response = await fireAndForgetFetch(fetchImpl)(BATCH_URL, BATCH_OPTIONS); + const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); expect(response.status).toBe(200); expect(await response.text()).toBe(""); @@ -29,9 +34,12 @@ describe("fireAndForgetFetch", () => { }); it("reports success on error responses so the SDK never retries or logs", async () => { - const fetchImpl = async () => new Response("Proxy Authentication Required", { status: 407 }); + vi.stubGlobal( + "fetch", + async () => new Response("Proxy Authentication Required", { status: 407 }), + ); - const response = await fireAndForgetFetch(fetchImpl)(BATCH_URL, BATCH_OPTIONS); + const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); expect(response.status).toBe(200); expect(await response.text()).toBe(""); From 42460002b0b14e48dee5e5e7b15a9a06c2d4a882 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 17 Jul 2026 13:56:58 +0800 Subject: [PATCH 6/7] fix(cli): catch posthog shutdown rejection --- apps/cli/src/shared/telemetry/posthog-client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index add87043d0..f153968880 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -33,5 +33,9 @@ export const scopedPosthogClient = (apiKey: string, host: string) => fetch: fireAndForgetFetch, }), ), - (client) => Effect.promise(() => client._shutdown(5_000)).pipe(Effect.ignore), + // The rejection must be caught on the promise itself: Effect.promise turns + // a rejection into a defect, which Effect.ignore does not swallow, so a + // shutdown timeout would fail the whole command (exit 1 with an + // UnknownError, observed on v2.109.1 whenever PostHog was unreachable). + (client) => Effect.promise(() => client._shutdown(5_000).catch(() => undefined)), ); From 6133c2d4b2759747a0b501d8dd5f80c409257fc1 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 17 Jul 2026 14:02:40 +0800 Subject: [PATCH 7/7] chore(cli): trim telemetry client comments --- apps/cli/src/shared/telemetry/posthog-client.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index f153968880..612c7cb573 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -7,11 +7,8 @@ const delivered = { json: () => Promise.resolve({}), }; -// posthog-node prints delivery failures through hardcoded console.error calls -// (no logger hook) and retries them with multi-second delays; reporting every -// attempt as delivered keeps failures silent and off the critical path. The -// 2s requestTimeout bounds how long a blackholed connection can hold process -// exit, since shutdown awaits in-flight sends. +// posthog-node has no logger hook: delivery failures hit hardcoded +// console.error calls and multi-second retries, so report them as delivered. export const fireAndForgetFetch: NonNullable = async (url, options) => { try { const response = await globalThis.fetch(url, options); @@ -33,9 +30,7 @@ export const scopedPosthogClient = (apiKey: string, host: string) => fetch: fireAndForgetFetch, }), ), - // The rejection must be caught on the promise itself: Effect.promise turns - // a rejection into a defect, which Effect.ignore does not swallow, so a - // shutdown timeout would fail the whole command (exit 1 with an - // UnknownError, observed on v2.109.1 whenever PostHog was unreachable). + // Catch on the promise: Effect.promise turns rejections into defects, + // which escape Effect.ignore and fail the command. (client) => Effect.promise(() => client._shutdown(5_000).catch(() => undefined)), );