Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silent-telemetry-flush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'stash': patch
---

Fix telemetry stack traces printing to the terminal when the telemetry endpoint is unreachable or returns an error. The PostHog SDK logs flush failures to the console internally (bypassing the CLI's own error swallowing), so a machine with telemetry enabled and a failing network printed two full stack-trace blocks per command. The CLI now supplies the SDK with a fetch wrapper that absorbs network and HTTP errors, so a failed send is silently dropped — matching the documented fire-and-forget behaviour. Command output, including `stash manifest --json` stdout, was never corrupted; the noise went to stderr.
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ describe('telemetry lifecycle (emitter + flush)', () => {
})
await t.shutdownTelemetry()
expect(h.PostHog).toHaveBeenCalledTimes(1)
// Pin the CIP-3587 fix: the client MUST be constructed with silentFetch,
// or a failed send prints the SDK's internal stack traces (logFlushError
// writes to console.error before our .catch() swallows can run).
expect(h.PostHog.mock.calls[0][1].fetch).toBe(t.silentFetch)
expect(h.capture).toHaveBeenCalledTimes(1)
const arg = h.capture.mock.calls[0][0]
expect(arg.distinctId).toBe('anon-x')
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/telemetry/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PLACEHOLDER_KEY,
resolveStatus,
sanitize,
silentFetch,
type TelemetryStatus,
} from '../index.js'
import type { TelemetryState } from '../state.js'
Expand Down Expand Up @@ -209,3 +210,74 @@ describe('sanitize (property allowlist)', () => {
}
})
})

describe('silentFetch (CIP-3587: failed sends must never print)', () => {
const request = {
method: 'POST' as const,
headers: { 'Content-Type': 'application/json' },
body: '{}',
}

afterEach(() => {
vi.unstubAllGlobals()
})

it('passes a successful response through untouched, forwarding url and options verbatim', async () => {
const real = { status: 200, text: async () => 'ok', json: async () => ({}) }
const fetchMock = vi.fn().mockResolvedValue(real)
vi.stubGlobal('fetch', fetchMock)
await expect(silentFetch('https://t.example', request)).resolves.toBe(real)
// The options object (which carries the SDK's AbortSignal) must reach the
// real fetch unmodified — dropping it would unbind requestTimeout and break
// the bounded-flush guarantee the wrapper's doc comment promises.
expect(fetchMock).toHaveBeenCalledWith('https://t.example', request)
})

it('swallows a network error into a stub 200 (no throw, nothing for the SDK to log)', async () => {
// The repro: an unreachable endpoint. @posthog/core turns a rejected fetch
// into PostHogFetchNetworkError and console.errors the full stack trace.
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new TypeError('fetch failed')),
)
const res = await silentFetch('https://t.example', request)
expect(res.status).toBe(200)
await expect(res.text()).resolves.toBe('')
})

it('swallows a timeout abort into a stub 200', async () => {
// The SDK aborts via AbortController after requestTimeout; fetch rejects
// with an AbortError DOMException, which must be swallowed like any other
// network failure.
vi.stubGlobal(
'fetch',
vi
.fn()
.mockRejectedValue(
new DOMException('This operation was aborted', 'AbortError'),
),
)
const res = await silentFetch('https://t.example', request)
expect(res.status).toBe(200)
})

it('swallows an HTTP error status into a stub 200 and drains the real body', async () => {
// status >= 400 makes the SDK throw PostHogFetchHttpError → same log path.
// The discarded response's body must be cancelled so undici releases the
// connection — a held socket outlives the bounded flush and keeps the
// process alive after output.
const cancel = vi.fn(async () => {})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 503,
text: async () => '',
json: async () => ({}),
body: { cancel },
}),
)
const res = await silentFetch('https://t.example', request)
expect(res.status).toBe(200)
expect(cancel).toHaveBeenCalledTimes(1)
})
})
47 changes: 46 additions & 1 deletion packages/cli/src/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PostHog } from 'posthog-node'
import type { PostHog, PostHogOptions } from 'posthog-node'
import { isCiEnvBroad, resolveCaller } from '../config/tty.js'
import { messages } from '../messages.js'
import { readState, type TelemetryState, writeState } from './state.js'
Expand Down Expand Up @@ -203,6 +203,49 @@ export function telemetryStatus(): TelemetryStatus {
return init().status
}

/**
* Fetch wrapper that makes a failed send invisible to the SDK. On any network
* error or HTTP error status, `@posthog/core`'s `logFlushError` writes the full
* error — nested stack traces included — straight to `console.error`, from both
* the capture-triggered background flush and the shutdown flush. That happens
* INSIDE the SDK before it swallows the rejection, so our own `.catch(() => {})`
* swallows never see it (CIP-3587 / #740: an unreachable telemetry endpoint
* printed two stack-trace blocks per command). Returning a stub 200 for every
* failure means the SDK never observes an error, so it never logs.
* The SDK's per-request AbortSignal (requestTimeout) arrives via `options.signal`
* and is passed through, so the bounded-flush guarantee is preserved.
*
* Two deliberate trade-offs, both safe only under this client's config:
* - Faking success defeats the SDK's failure handling (queue retention on
* network error, 413 batch-splitting). With `flushAt: 1` and
* `fetchRetryCount: 0` those branches were already unreachable — the event
* was dropped either way — but revisit this if batching is ever enabled.
* - This client must only ever send capture batches. The stub `{}` reads as a
* valid success payload to the SDK's JSON-parsing endpoints (feature flags,
* remote config), so adding those would return confidently wrong answers on
* any failure.
*/
export const silentFetch: NonNullable<PostHogOptions['fetch']> = async (
url,
options,
) => {
try {
const res = await fetch(url, options)
// Mirror the SDK's own success window (it throws outside 200–399).
if (res.status >= 200 && res.status < 400) return res
// Drain the discarded error response so undici releases the connection.
// The SDK's old error path did this via `await err.text`; without it the
// socket outlives the bounded flush and keeps the process alive.
await res.body?.cancel()
} catch {
// Anything that failed to produce a usable response falls through to the
// stub: unreachable endpoint, DNS failure, timeout abort — and also
// request-construction failures (e.g. a malformed STASH_POSTHOG_HOST),
// which are swallowed the same as network-down.
}
return { status: 200, text: async () => '', json: async () => ({}) }
}

async function getClient(): Promise<PostHog> {
if (clientPromise === null) {
clientPromise = import('posthog-node').then(
Expand All @@ -222,6 +265,8 @@ async function getClient(): Promise<PostHog> {
requestTimeout: FLUSH_TIMEOUT_MS,
// Never resolve IP → geo; we don't want or store location.
disableGeoip: true,
// Failures must never print — see silentFetch.
fetch: silentFetch,
}),
)
}
Expand Down
Loading