Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
3db04a2
fix(core): honor Retry-After and skip oversized bodies on the OTLP qu…
turnipdabeets Sep 1, 2026
39f5d4d
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets Sep 2, 2026
098b597
fix(core): honor Retry-After on every send path, not just the retry t…
turnipdabeets Sep 2, 2026
f766c21
chore: regenerate the terser mangled-names list
turnipdabeets Sep 2, 2026
9a2df41
fix(core): drain on explicit flush, and clamp the Retry-After deadline
turnipdabeets Sep 2, 2026
cc72d10
fix(core): stop an in-window refusal from extending the window
turnipdabeets Sep 2, 2026
95ce73e
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into tmp/4…
turnipdabeets Sep 3, 2026
af65292
Merge feat/traces-node-mvp into fix/otlp-honor-retry-after
turnipdabeets Sep 3, 2026
aea2b15
fix(core): keep the Retry-After window off the public API and out of …
turnipdabeets Sep 3, 2026
74ac214
fix(core): stop a closed Retry-After window from stranding the metric…
turnipdabeets Sep 3, 2026
b91c94c
fix(core): release a queue once the Retry-After window that held it c…
turnipdabeets Sep 3, 2026
055e0b8
fix(traces): charge the retry budget once per backoff window
turnipdabeets Sep 3, 2026
829f41f
chore(core): declare posthog-js and trim the retry-after comments
turnipdabeets Sep 3, 2026
de06acd
fix(core): measure OTLP bodies against the configured limit, not the …
turnipdabeets Sep 4, 2026
ea4e9a2
chore(core): correct the Retry-After parser and window comments
turnipdabeets Sep 4, 2026
59bc392
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets Sep 4, 2026
5885f79
chore(react-native): regenerate references for the body-cap doc wording
turnipdabeets Sep 4, 2026
3562b39
Merge feat/traces-node-mvp into fix/otlp-honor-retry-after
turnipdabeets Sep 4, 2026
5fa7aca
refactor(core): hold the flush timer in one place for all three queues
turnipdabeets Sep 4, 2026
dd5e9be
chore(browser): regenerate mangled property names after the FlushTime…
turnipdabeets Sep 4, 2026
659f4e9
fix(traces): reset the head batch budget when consent withdrawal clea…
turnipdabeets Sep 4, 2026
0e59eb8
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into work/…
turnipdabeets Sep 4, 2026
124b6e9
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into work/…
turnipdabeets Sep 4, 2026
366c141
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into work/…
turnipdabeets Sep 4, 2026
d06a6b2
docs: one-line the OTLP changesets and split the browser-visible half
turnipdabeets Sep 4, 2026
fa42a17
fix(core): report an unserializable OTLP batch as too-large
turnipdabeets Sep 4, 2026
d1007d0
docs(core): correct the unserializable-batch test comment, drop a dup…
turnipdabeets Sep 4, 2026
6df30f4
docs: scope the OTLP entries to logs and metrics
turnipdabeets Sep 4, 2026
030c294
docs(core): correct where the ingestion body limit is applied
turnipdabeets Sep 4, 2026
7713aef
docs(core): the ingestion limit sees the decompressed body
turnipdabeets Sep 8, 2026
4fb5bf1
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into tmp/4…
turnipdabeets Sep 8, 2026
7ef472b
Merge branch 'feat/traces-node-mvp' into fix/otlp-honor-retry-after
turnipdabeets Sep 8, 2026
4db1e85
chore: restore generated reference files to generator output
turnipdabeets Sep 8, 2026
6ee9467
fix(traces): hold spans back during Retry-After and split locally
turnipdabeets Sep 8, 2026
5ee37fb
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into sync/…
turnipdabeets Sep 8, 2026
d5c64e2
fix(otlp): extend an open Retry-After window and jitter our own backoff
turnipdabeets Sep 8, 2026
b21fce3
docs(changeset): name the retry-window and backoff changes
turnipdabeets Sep 8, 2026
3279b97
fix(otlp): cap the logs and metrics backoff at 30s, as the contracts …
turnipdabeets Sep 8, 2026
d1b93bc
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/4…
turnipdabeets Sep 8, 2026
3a749d0
chore(browser): record _flushJitter in the mangled property names
turnipdabeets Sep 8, 2026
69d0475
chore(references): regenerate for the measuredLocally too-large outcome
turnipdabeets Sep 8, 2026
8e95248
docs(changeset): disclose the 30s cap on the logs retry delay
turnipdabeets Sep 8, 2026
4d02232
chore: restore generated reference files to generator output
turnipdabeets Sep 8, 2026
9b763ec
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets Sep 10, 2026
c7e1802
fix(core): leave an exempt flush uncharged when a later refusal exten…
turnipdabeets Sep 10, 2026
7406bbc
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets Sep 10, 2026
842dcb9
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets Sep 10, 2026
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
7 changes: 7 additions & 0 deletions .changeset/logs-backoff-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-js': patch
'posthog-react-native': patch
'@posthog/core': patch
---

Cap the retry delay for log exports at 30 seconds, the ceiling the logs contract states. It previously doubled to 64 times the flush interval — 192s on web, 640s on React Native — so a log export now resumes within 30 seconds of a failing endpoint recovering, at the cost of more retry requests while that endpoint is down.
7 changes: 7 additions & 0 deletions .changeset/logs-backoff-survives-captures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-js': patch
'posthog-react-native': patch
'@posthog/core': patch
---

Keep backing off a failing log flush while new records arrive, instead of the next record resetting the retry to the flush interval.
7 changes: 7 additions & 0 deletions .changeset/otlp-honor-retry-after.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-node': patch
'posthog-react-native': patch
'@posthog/core': patch
Comment thread
turnipdabeets marked this conversation as resolved.
---

Honor `Retry-After` when the ingestion endpoint refuses a logs or metrics batch, instead of retrying on the SDK's own schedule alone. A refusal naming a longer wait extends the one being served, up to five minutes from when it started. Retry delays now carry jitter so clients refused together do not return together, and metrics backs off exponentially across consecutive failures rather than retrying on a fixed interval.
7 changes: 7 additions & 0 deletions .changeset/otlp-skip-oversized-bodies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-node': patch
'posthog-react-native': patch
'@posthog/core': patch
---

Stop sending logs and metrics batches over 10 MiB, or too large to serialize at all, instead of spending a request to discover the endpoint refuses them.
8 changes: 7 additions & 1 deletion packages/browser/terser-mangled-names.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@
"_clearDebouncer",
"_clearFlushBufferTimer",
"_clearFlushTimeout",
"_clearFlushTimer",
"_clearMouseSelection",
"_clearPointer",
"_clearSessionRegisteredProps",
Expand Down Expand Up @@ -235,6 +234,7 @@
"_finishQueuedCompressionEvent",
"_finishSetup",
"_fireFeatureFlagsCallbacks",
"_firesAt",
"_flagListenerCleanup",
"_flagToExperiments",
"_flagsLoadedFromRemote",
Expand All @@ -247,6 +247,7 @@
"_flushInner",
"_flushInterval",
"_flushIntervalMs",
"_flushJitter",
"_flushPendingActivityTimestamp",
"_flushPromise",
"_flushTimeout",
Expand Down Expand Up @@ -382,6 +383,7 @@
"_initializeWidgetPromise",
"_initialized",
"_initializingClient",
"_installedAt",
"_internalEventEmitter",
"_internalFlagCheckSatisfied",
"_intervalLogCount",
Expand Down Expand Up @@ -549,6 +551,7 @@
"_onClick",
"_onClickHandler",
"_onDeadClick",
"_onFire",
"_onFocusChange",
"_onIdentityChanged",
"_onIdentityCleared",
Expand Down Expand Up @@ -747,6 +750,7 @@
"_resumeSavedTour",
"_resyncIntervalMs",
"_resyncTimer",
"_retryAfter",
"_retryQueue",
"_rrwebError",
"_rrwebStartAttempted",
Expand Down Expand Up @@ -887,6 +891,7 @@
"_teardown",
"_throttledMutationsDropped",
"_tickets",
"_timer",
"_timestamp",
"_totalBytes",
"_touchStart",
Expand Down Expand Up @@ -916,6 +921,7 @@
"_unsubscribeFeatureFlags",
"_unsubscribeIdentifyListener",
"_unsubscribeSessionId",
"_until",
"_unwrapConsoleError",
"_unwrapOnError",
"_unwrapUnhandledRejection",
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/__tests__/flush-timer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { FlushTimer } from '../utils/flush-timer'

describe('FlushTimer', () => {
it('fires once, after the delay', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.arm(1000)

await vi.advanceTimersByTimeAsync(999)
expect(onFire).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(onFire).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(10_000)
expect(onFire).toHaveBeenCalledTimes(1)
})

it('releases the handle before firing, so the callback can arm again', async () => {
const timer: FlushTimer = new FlushTimer(() => {
pendingInsideCallback = timer.pending
})
let pendingInsideCallback: boolean | undefined
timer.arm(1000)
expect(timer.pending).toBe(true)

await vi.advanceTimersByTimeAsync(1000)
expect(pendingInsideCallback).toBe(false)
expect(timer.pending).toBe(false)
})

it('arm replaces a pending timer outright, in either direction', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.arm(10_000)
timer.arm(1000)

await vi.advanceTimersByTimeAsync(1000)
expect(onFire).toHaveBeenCalledTimes(1)
})

// The reason the deadline lives next to the handle: every capture reaches the
// arming path, and none of them may pull a flush in front of a longer wait.
it('armNoEarlierThan does not shorten a pending timer', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.arm(10_000)
timer.armNoEarlierThan(1000)

await vi.advanceTimersByTimeAsync(9999)
expect(onFire).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(onFire).toHaveBeenCalledTimes(1)
})

it('armNoEarlierThan lengthens a pending timer', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.arm(1000)
timer.armNoEarlierThan(10_000)

await vi.advanceTimersByTimeAsync(1000)
expect(onFire).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(9000)
expect(onFire).toHaveBeenCalledTimes(1)
})

it('armNoEarlierThan counts down the remainder, not the whole delay', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.arm(10_000)
await vi.advanceTimersByTimeAsync(6000)
// 4s left, so a 4s request is not longer and must not restart the wait.
timer.armNoEarlierThan(4000)

await vi.advanceTimersByTimeAsync(4000)
expect(onFire).toHaveBeenCalledTimes(1)
})

it('armNoEarlierThan arms when nothing is pending', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.armNoEarlierThan(1000)
expect(timer.pending).toBe(true)

await vi.advanceTimersByTimeAsync(1000)
expect(onFire).toHaveBeenCalledTimes(1)
})

it('clear stops a pending timer and is safe to repeat', async () => {
const onFire = vi.fn()
const timer = new FlushTimer(onFire)
timer.arm(1000)
timer.clear()
timer.clear()
expect(timer.pending).toBe(false)

await vi.advanceTimersByTimeAsync(10_000)
expect(onFire).not.toHaveBeenCalled()
})
})
108 changes: 108 additions & 0 deletions packages/core/src/__tests__/posthog.otlp-retry-after.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from '@/testing'

// The header has to survive the whole path: response → PostHogFetchHttpError →
// the retry-later outcome each queue reads. A unit test of the parser alone
// would still pass if the plumbing were missing.
describe('OTLP Retry-After', () => {
let posthog: PostHogCoreTestClient
let mocks: PostHogCoreTestClientMocks

const respondWith = (status: number, retryAfter?: string): void => {
mocks.fetch.mockResolvedValue({
status,
text: () => Promise.resolve(''),
json: () => Promise.resolve({}),
headers: { get: (name: string) => (name.toLowerCase() === 'retry-after' && retryAfter ? retryAfter : null) },
})
}

beforeEach(() => {
;[posthog, mocks] = createTestClient('TEST_API_KEY', {
host: 'http://example.com',
preloadFeatureFlags: false,
disableCompression: true,
fetchRetryCount: 0,
})
})

it.each([
['logs', () => posthog._sendLogsBatch({ resourceLogs: [] } as any)],
['metrics', () => posthog._sendMetricsBatch({ resourceMetrics: [] } as any)],
['traces', () => posthog._sendTracesBatch({ resourceSpans: [] } as any)],
])("surfaces the endpoint's Retry-After to the %s queue", async (_signal, send) => {
respondWith(429, '120')

const outcome = await send()

expect(outcome).toMatchObject({ kind: 'retry-later', retryAfterMs: 120_000 })
})

it('sends once, not once per inner retry, when the endpoint names a wait', async () => {
// The inner retriable loop retries on a short fixed delay. Spending its
// attempts here would put three extra requests inside the very window the
// queue is about to back off for.
const [client, clientMocks] = createTestClient('TEST_API_KEY', {
host: 'http://example.com',
preloadFeatureFlags: false,
disableCompression: true,
})
clientMocks.fetch.mockResolvedValue({
status: 429,
text: () => Promise.resolve(''),
json: () => Promise.resolve({}),
headers: { get: (name: string) => (name.toLowerCase() === 'retry-after' ? '120' : null) },
})

const pending = client._sendTracesBatch({ resourceSpans: [] } as any)
await vi.advanceTimersByTimeAsync(60_000)

expect(await pending).toMatchObject({ kind: 'retry-later', retryAfterMs: 120_000 })
expect(clientMocks.fetch).toHaveBeenCalledTimes(1)
})

it('still retries internally when the response names no wait', async () => {
const [client, clientMocks] = createTestClient('TEST_API_KEY', {
host: 'http://example.com',
preloadFeatureFlags: false,
disableCompression: true,
})
clientMocks.fetch.mockResolvedValue({
status: 503,
text: () => Promise.resolve(''),
json: () => Promise.resolve({}),
headers: { get: () => null },
})

const pending = client._sendTracesBatch({ resourceSpans: [] } as any)
await vi.advanceTimersByTimeAsync(60_000)

expect((await pending).kind).toBe('retry-later')
expect(clientMocks.fetch.mock.calls.length).toBeGreaterThan(1)
})

it('leaves retryAfterMs unset when the response sends no header', async () => {
respondWith(503)

const outcome = await posthog._sendTracesBatch({ resourceSpans: [] } as any)

expect(outcome.kind).toBe('retry-later')
expect((outcome as { retryAfterMs?: number }).retryAfterMs).toBeUndefined()
})

it('survives a transport whose headers accessor throws', async () => {
mocks.fetch.mockResolvedValue({
status: 503,
text: () => Promise.resolve(''),
json: () => Promise.resolve({}),
headers: {
get: () => {
throw new Error('hostile transport')
},
},
})

const outcome = await posthog._sendTracesBatch({ resourceSpans: [] } as any)

expect(outcome.kind).toBe('retry-later')
})
})
Loading
Loading