-
Notifications
You must be signed in to change notification settings - Fork 500
fix(cli): stop telemetry hangs and errors on blocked networks #5880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pamelachia
wants to merge
15
commits into
develop
Choose a base branch
from
pamela/cli-telemetry-network-resilience
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
baa3c5a
fix(cli): bound posthog close, quiet sdk logs
pamelachia a96c6f5
fix(cli): fire-and-forget posthog fetch in ts cli
pamelachia e38cb95
test(cli): cover posthog logger routing and scoped client
pamelachia c367a3e
fix(cli): cap go telemetry close at 2s
pamelachia 77ce839
fix(cli): 2s telemetry request timeout, flatten factory
pamelachia 4246000
fix(cli): catch posthog shutdown rejection
pamelachia 6133c2d
chore(cli): trim telemetry client comments
pamelachia 8bec921
Merge branch 'develop' into pamela/cli-telemetry-network-resilience
pamelachia 878576b
fix(cli): bound posthog shutdown with a single deadline
pamelachia cddfce7
Merge branch 'develop' into pamela/cli-telemetry-network-resilience
pamelachia 5fbf9cd
fix(cli): cancel outstanding posthog requests at shutdown deadline
pamelachia 7f94ef1
Merge remote-tracking branch 'origin/develop' into pamela/cli-telemet…
pamelachia 35894b9
test(cli): make blackhole e2e exercise live telemetry
pamelachia 365451d
Merge remote-tracking branch 'origin/pamela/cli-telemetry-network-res…
pamelachia a7e032d
test(cli): widen e2e exit threshold for ci jitter
pamelachia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { createServer, type Server, type Socket } from "node:net"; | ||
| import { afterAll, beforeAll, describe, expect, test } from "vitest"; | ||
| import { runSupabase } from "../../../tests/helpers/cli.ts"; | ||
|
|
||
| // A TCP blackhole: accepts connections and never responds, so telemetry | ||
| // requests connect and then hang until aborted. Asserting on the spawned | ||
| // process's wall-clock exit (not scope or shutdown internals) is deliberate: | ||
| // pending sockets keep the runtime alive, so only actual process exit proves | ||
| // the telemetry exit cap holds end to end. | ||
| describe("telemetry against a blackholed PostHog endpoint", () => { | ||
| let server: Server; | ||
| let host: string; | ||
| let connections = 0; | ||
| const sockets = new Set<Socket>(); | ||
|
|
||
| beforeAll(async () => { | ||
| server = createServer((socket) => { | ||
| connections += 1; | ||
| sockets.add(socket); | ||
| // Aborted requests reset the connection; without a listener the | ||
| // server-side ECONNRESET becomes an uncaught exception. | ||
| socket.on("error", () => {}); | ||
| socket.on("close", () => sockets.delete(socket)); | ||
| }); | ||
| await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| const address = server.address(); | ||
| if (address === null || typeof address === "string") { | ||
| throw new Error("Failed to allocate a blackhole port"); | ||
| } | ||
| host = `http://127.0.0.1:${address.port}`; | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| for (const socket of sockets) socket.destroy(); | ||
| await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| }); | ||
|
|
||
| test("commands exit promptly, cleanly, and quietly", async () => { | ||
| const startedAt = performance.now(); | ||
| const { stdout, stderr, exitCode } = await runSupabase(["telemetry", "status"], { | ||
| entrypoint: "legacy", | ||
| env: { | ||
| // spawnSupabase disables telemetry for every test by default; this | ||
| // test exists to exercise it, so turn it back on explicitly. | ||
| SUPABASE_TELEMETRY_DISABLED: "0", | ||
| DO_NOT_TRACK: "0", | ||
| SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_e2e_blackhole_test", | ||
| SUPABASE_TELEMETRY_POSTHOG_HOST: host, | ||
| }, | ||
| }); | ||
| const elapsedMs = performance.now() - startedAt; | ||
|
|
||
| expect(exitCode).toBe(0); | ||
| expect(stdout).toContain("Telemetry is enabled."); | ||
| expect(stderr).toBe(""); | ||
| // Telemetry must have actually reached the blackhole, otherwise the | ||
| // timing assertion below passes vacuously with telemetry off. | ||
| expect(connections).toBeGreaterThanOrEqual(1); | ||
| // Healthy runs measure ~2.5s (2s drain cap + spawn overhead); the nearest | ||
| // real failure signature is the SDK's 5s default deadline plus startup. | ||
| expect(elapsedMs).toBeLessThan(4_500); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { Effect } from "effect"; | ||
| import { PostHog, type PostHogOptions } from "posthog-node"; | ||
|
|
||
| const EXIT_DELAY_CAP_MS = 2_000; | ||
|
|
||
| const delivered = { | ||
| status: 200, | ||
| text: () => Promise.resolve(""), | ||
| json: () => Promise.resolve({}), | ||
| }; | ||
|
|
||
| // 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<PostHogOptions["fetch"]> = async (url, options) => { | ||
| try { | ||
| const response = await globalThis.fetch(url, options); | ||
| return response.status >= 400 ? delivered : response; | ||
| } catch { | ||
| return delivered; | ||
| } | ||
| }; | ||
|
|
||
| export const scopedPosthogClient = (apiKey: string, host: string) => | ||
| Effect.acquireRelease( | ||
| Effect.sync(() => { | ||
| const shutdown = new AbortController(); | ||
| const client = new PostHog(apiKey, { | ||
| host, | ||
| flushAt: 1, | ||
| flushInterval: 0, | ||
| requestTimeout: EXIT_DELAY_CAP_MS, | ||
| fetch: (url, options) => | ||
| fireAndForgetFetch(url, { | ||
| ...options, | ||
| signal: options.signal | ||
| ? AbortSignal.any([options.signal, shutdown.signal]) | ||
| : shutdown.signal, | ||
| }), | ||
| }); | ||
| return { client, shutdown }; | ||
| }), | ||
| ({ client, shutdown }) => | ||
| Effect.promise(async () => { | ||
| try { | ||
| await client._shutdown(EXIT_DELAY_CAP_MS); | ||
| } catch { | ||
| // The deadline rejection must be swallowed: Effect.promise turns | ||
| // rejections into defects, which would fail the command. | ||
| } finally { | ||
| // The shutdown deadline only stops the wait; the SDK's drain keeps | ||
| // in-flight requests running and starts queued ones after release. | ||
| // Aborting cancels them so nothing outlives the scope. | ||
| shutdown.abort(); | ||
| } | ||
| }), | ||
| ).pipe(Effect.map(({ client }) => client)); |
107 changes: 107 additions & 0 deletions
107
apps/cli/src/shared/telemetry/posthog-client.unit.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| 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"; | ||
|
|
||
| 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 () => { | ||
| vi.stubGlobal("fetch", async () => new Response(`{"status":1}`, { status: 200 })); | ||
|
|
||
| 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 () => { | ||
| vi.stubGlobal("fetch", async () => { | ||
| throw new Error("connect ECONNREFUSED"); | ||
| }); | ||
|
|
||
| const response = await fireAndForgetFetch(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 () => { | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| async () => new Response("Proxy Authentication Required", { status: 407 }), | ||
| ); | ||
|
|
||
| const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(await response.text()).toBe(""); | ||
| }); | ||
| }); | ||
|
|
||
| describe("scopedPosthogClient", () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| 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), | ||
| ); | ||
|
|
||
| it.live( | ||
| "bounds the whole shutdown when a request is in flight and another event is queued", | ||
| () => | ||
| Effect.gen(function* () { | ||
| let requestStarted = () => {}; | ||
| const firstRequestInFlight = new Promise<void>((resolve) => { | ||
| requestStarted = resolve; | ||
| }); | ||
| let activeRequests = 0; | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| (_url: string, options: { signal?: AbortSignal }) => | ||
| new Promise<Response>((_resolve, reject) => { | ||
| activeRequests += 1; | ||
| requestStarted(); | ||
| const abort = () => { | ||
| activeRequests -= 1; | ||
| reject(new DOMException("The operation was aborted.", "AbortError")); | ||
| }; | ||
| if (options.signal?.aborted) { | ||
| abort(); | ||
| return; | ||
| } | ||
| options.signal?.addEventListener("abort", abort); | ||
| }), | ||
| ); | ||
|
|
||
| const startedAt = performance.now(); | ||
| yield* Effect.gen(function* () { | ||
| const client = yield* scopedPosthogClient("phc_test", "https://blackhole.invalid"); | ||
| client.capture({ event: "first_event", distinctId: "device-1" }); | ||
| yield* Effect.promise(() => firstRequestInFlight); | ||
| client.capture({ event: "second_event", distinctId: "device-1" }); | ||
| }).pipe(Effect.scoped); | ||
|
|
||
| expect(performance.now() - startedAt).toBeLessThan(3_000); | ||
|
|
||
| // The SDK's drain keeps running past the shutdown deadline; without | ||
| // cancellation it starts the queued request AFTER scope release and | ||
| // keeps the process alive for that request's own timeout. | ||
| yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); | ||
| expect(activeRequests).toBe(0); | ||
| }), | ||
| 10_000, | ||
| ); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.