PostHogCoreStateless has three near-identical OTLP senders — _sendLogsBatch, _sendMetricsBatch, and _sendTracesBatch. Each is ~50 lines and they differ only in the endpoint path and auth style. Nothing keeps them in sync, and they have already drifted in a way that loses data.
The drift
All three classify a send failure into the same tagged outcome, but not by the same rule:
| sender |
retry-later when |
_sendLogsBatch |
err instanceof PostHogFetchNetworkError |
_sendMetricsBatch |
isPostHogFetchRetryableError(err) |
_sendTracesBatch |
isPostHogFetchRetryableError(err) |
isPostHogFetchRetryableError returns true for HTTP 408, 429, and ≥500, or a network error. PostHogFetchNetworkError is network-only.
So once fetchWithRetry exhausts its internal retries against a struggling ingestion endpoint:
- logs classifies the 429/5xx as
fatal and drops the batch
- metrics and traces classify it as
retry-later and keep the batch queued for the next flush
Logs is the outlier — the two senders written later both use the broader predicate, and _sendMetricsBatch carries a comment explaining the choice. This looks like drift rather than a deliberate decision, but either way the behaviour isn't documented as intentional anywhere.
Why extracting helps
The bodies are otherwise identical: disabled check, JSON.stringify, gzip unless disableCompression, custom headers, fetchWithRetry with a retryCheck that excludes 413, then the same four-way outcome mapping.
A single private _sendOtlpBatch({ path, auth, payload }) returning SendBatchOutcome collapses ~150 lines to ~15 plus three thin wrappers, and makes the retry policy one decision instead of three copies that can silently disagree.
Notes
- Fixing the logs classification is a behaviour change to a shipped pipeline (batches that are currently dropped would start being retried), so it deserves its own verification rather than riding along silently in a refactor.
- Purely internal — no public API surface involved.
PostHogCoreStatelesshas three near-identical OTLP senders —_sendLogsBatch,_sendMetricsBatch, and_sendTracesBatch. Each is ~50 lines and they differ only in the endpoint path and auth style. Nothing keeps them in sync, and they have already drifted in a way that loses data.The drift
All three classify a send failure into the same tagged outcome, but not by the same rule:
_sendLogsBatcherr instanceof PostHogFetchNetworkError_sendMetricsBatchisPostHogFetchRetryableError(err)_sendTracesBatchisPostHogFetchRetryableError(err)isPostHogFetchRetryableErrorreturns true for HTTP 408, 429, and ≥500, or a network error.PostHogFetchNetworkErroris network-only.So once
fetchWithRetryexhausts its internal retries against a struggling ingestion endpoint:fataland drops the batchretry-laterand keep the batch queued for the next flushLogs is the outlier — the two senders written later both use the broader predicate, and
_sendMetricsBatchcarries a comment explaining the choice. This looks like drift rather than a deliberate decision, but either way the behaviour isn't documented as intentional anywhere.Why extracting helps
The bodies are otherwise identical: disabled check,
JSON.stringify, gzip unlessdisableCompression, custom headers,fetchWithRetrywith aretryCheckthat excludes 413, then the same four-way outcome mapping.A single private
_sendOtlpBatch({ path, auth, payload })returningSendBatchOutcomecollapses ~150 lines to ~15 plus three thin wrappers, and makes the retry policy one decision instead of three copies that can silently disagree.Notes