import { type FetchLike, type PostHogEventProperties, type PostHogFetchBodyBytes, type PostHogFetchOptions, type PostHogFetchResponse, gzipCompress, safeJsonStringify, safeSetTimeout, uuidv7, } from '@posthog/core' import { CaptureV1Error, type V1DroppedEvent } from './errors' import { buildV1Batch } from './transform' import type { V1BatchResponse, V1Event } from './types' const V1_ANALYTICS_PATH = '/i/v1/analytics/events' const DEFAULT_MAX_BACKOFF_MS = 30_000 /** HTTP statuses the v1 backend wants retried. 429 is intentionally terminal in v1. */ const RETRYABLE_STATUSES = new Set([408, 500, 502, 503, 504]) export interface V1CaptureSenderConfig { host: string apiKey: string /** Canonical `$lib` identity, materialized by the server from `PostHog-Sdk-Info`. */ libraryId: string libraryVersion: string /** Value for the `User-Agent` header, if the runtime allows setting it. */ userAgent?: string historicalMigration: boolean /** When true, the body is gzip-compressed (falling back to uncompressed on failure). */ compressionEnabled: boolean requestTimeoutMs: number /** Total attempts, including the first (1 initial + N retries). */ maxAttempts: number /** Base exponential backoff, doubled each retry and capped at `maxBackoffMs`. */ initialRetryDelayMs: number /** Single ceiling for both the backoff schedule and the `Retry-After` clamp. */ maxBackoffMs?: number isDebug?: boolean } export interface V1CaptureSenderHooks { fetch: FetchLike /** Surfaces partial/terminal delivery failures on the client error channel. */ onError: (error: Error) => void now?: () => number sleep?: (ms: number) => Promise generateRequestId?: () => string compress?: (payload: string, isDebug?: boolean) => Promise } interface V1CaptureAttempt { response: PostHogFetchResponse signal: AbortSignal waitFor(operation: Promise): Promise cancelBody: () => Promise cleanup: () => void } /** * Sends already-normalized events to the Capture V1 endpoint * (`POST /i/v1/analytics/events`), owning the full attempt loop: per-event * partial retry, exponential backoff clamped against `Retry-After`, v1 status * classification, and surfacing drops / undelivered events on the error * channel. Used by both the batched and immediate send paths. * * Never throws for a handled outcome: after exhausting its own attempt budget it * reports the failure via `onError` and resolves, so the caller's queue treats * the batch as consumed (the internal budget replaces v0's queue-level retry). */ export class V1CaptureSender { private readonly config: V1CaptureSenderConfig private readonly maxBackoffMs: number private readonly fetchFn: FetchLike private readonly onError: (error: Error) => void private readonly now: () => number private readonly sleep: (ms: number) => Promise private readonly generateRequestId: () => string private readonly compress: (payload: string, isDebug?: boolean) => Promise constructor(config: V1CaptureSenderConfig, hooks: V1CaptureSenderHooks) { this.config = config this.maxBackoffMs = config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS this.fetchFn = hooks.fetch this.onError = hooks.onError this.now = hooks.now ?? Date.now this.sleep = hooks.sleep ?? ((ms) => new Promise((resolve) => safeSetTimeout(resolve, ms))) this.generateRequestId = hooks.generateRequestId ?? uuidv7 this.compress = hooks.compress ?? gzipCompress } async sendV1Batch(messages: PostHogEventProperties[]): Promise { if (messages.length === 0) { return } const requestId = this.generateRequestId() // created_at is generated once and stays stable across every retry. const createdAt = new Date(this.now()).toISOString() const { batch } = buildV1Batch(messages, { createdAt, historicalMigration: this.config.historicalMigration, }) const url = `${this.config.host}${V1_ANALYTICS_PATH}` const drops: V1DroppedEvent[] = [] let pending = batch // Guard against a misconfigured budget (e.g. fetchRetryCount: -1): always make // at least one attempt so a bad config surfaces a delivery error instead of // silently dropping the batch without ever sending or calling onError. const maxAttempts = Math.max(1, this.config.maxAttempts) for (let attempt = 1; attempt <= maxAttempts; attempt++) { const isLastAttempt = attempt === maxAttempts const payload = safeJsonStringify({ created_at: createdAt, ...(this.config.historicalMigration ? { historical_migration: true } : {}), batch: pending, }) let captureAttempt: V1CaptureAttempt | undefined let retryDelayMs: number | undefined try { captureAttempt = await this.sendOnce(url, payload, attempt, requestId) const { response, signal } = captureAttempt const { status } = response if (status < 200 || status >= 300) { if (!isLastAttempt && RETRYABLE_STATUSES.has(status)) { const retryAfterMs = this.parseRetryAfter(response) await captureAttempt.waitFor(captureAttempt.cancelBody()).catch(() => {}) retryDelayMs = this.backoffDelay(attempt, retryAfterMs) } else { const httpError = await this.buildHttpError(response, status, captureAttempt.waitFor) return this.surfaceBatchFailure(requestId, drops, pending, httpError) } } else { let parsed: V1BatchResponse try { parsed = await captureAttempt.waitFor(this.parseResponse(response)) } catch (error) { if (signal.aborted) { throw error } // A 2xx we cannot parse is terminal — re-sending against a broken success would loop forever. return this.surfaceBatchFailure( requestId, drops, pending, new Error(`Capture V1 returned an unparseable ${status} response body`) ) } const retryable = this.classify(pending, parsed, drops) if (retryable.length === 0) { return this.surfacePartialDrops(requestId, drops) } if (isLastAttempt) { this.onError(new CaptureV1Error({ requestId, drops, retryExhausted: retryable.map((event) => event.uuid) })) return } pending = retryable // A 200 partial-retry body can also carry Retry-After (e.g. rate-limited // retry events); honor it as a minimum the same way as on a retryable status. const retryAfterMs = this.parseRetryAfter(response) retryDelayMs = this.backoffDelay(attempt, retryAfterMs) } } catch (transportError) { if (captureAttempt && !captureAttempt.signal.aborted) { throw transportError } // Connection/timeouts, including a timeout while consuming the response body, // are retried until the budget runs out. if (isLastAttempt) { return this.surfaceBatchFailure(requestId, drops, pending, transportError) } retryDelayMs = this.backoffDelay(attempt) } finally { captureAttempt?.cleanup() } if (retryDelayMs !== undefined) { await this.sleep(retryDelayMs) } } } private async sendOnce(url: string, payload: string, attempt: number, requestId: string): Promise { const headers = this.buildHeaders(attempt, requestId) let body: string | Blob | PostHogFetchBodyBytes = payload if (this.config.compressionEnabled) { const compressed = await this.compress(payload, this.config.isDebug) if (compressed !== null) { body = compressed headers['Content-Encoding'] = 'gzip' } // A codec that fails to compress falls back to sending uncompressed. } const controller = new AbortController() let timeoutPhase = 'waiting for response headers' let timer: ReturnType // hooks.fetch is user-injectable, so it may ignore an abort signal while // resolving headers or reading the body. Race both phases against the same // deadline rather than relying only on standards-compliant fetch behavior. const deadline = new Promise((_resolve, reject) => { timer = safeSetTimeout(() => { const timeoutError = new Error( `Capture V1 request timed out ${timeoutPhase} after ${this.config.requestTimeoutMs}ms` ) timeoutError.name = 'AbortError' // Reject first so this phase-specific error wins even when an abort-aware // custom fetch rejects synchronously from the abort event. reject(timeoutError) controller.abort(timeoutError) }, this.config.requestTimeoutMs) }) try { const fetchPromise = this.fetchFn(url, { method: 'POST', headers, body, signal: controller.signal }) let responseAccepted = false void fetchPromise .then((lateResponse) => { if (controller.signal.aborted && !responseAccepted) { // An injected fetch may ignore abort and resolve after the deadline. // Cancel its otherwise unreachable body to release any held resources. void this.cancelBody(lateResponse) } }) .catch(() => {}) const response = await Promise.race([fetchPromise, deadline]) responseAccepted = true timeoutPhase = 'while reading the response body' let cleanedUp = false let cancellation: Promise | undefined const cancelBody = (): Promise => (cancellation ??= this.cancelBody(response)) return { response, signal: controller.signal, waitFor: (operation: Promise) => Promise.race([operation, deadline]), cancelBody, cleanup: () => { if (cleanedUp) { return } cleanedUp = true clearTimeout(timer) if (controller.signal.aborted) { // The attempt deadline has already elapsed, so cleanup cannot wait // for a custom response body's cancellation promise to settle. void cancelBody() } }, } } catch (error) { clearTimeout(timer) throw error } } private buildHeaders(attempt: number, requestId: string): PostHogFetchOptions['headers'] { const sdkInfo = `${this.config.libraryId}/${this.config.libraryVersion}` const headers: { [key: string]: string } = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}`, 'PostHog-Sdk-Info': sdkInfo, 'PostHog-Attempt': String(attempt), 'PostHog-Request-Id': requestId, // Regenerated per attempt — a per-attempt wall-clock stamp, unlike Request-Id / created_at. 'PostHog-Request-Timestamp': new Date(this.now()).toISOString(), } if (this.config.userAgent) { headers['User-Agent'] = this.config.userAgent } return headers } /** * Partition the pending events against a 2xx result map: `drop` accumulates as * a terminal failure, `retry` is returned for the next attempt, and * `ok`/`warning`/unknown/absent are terminal successes. */ private classify(pending: V1Event[], parsed: V1BatchResponse, drops: V1DroppedEvent[]): V1Event[] { const results = parsed.results ?? {} const retryable: V1Event[] = [] for (const event of pending) { const result = results[event.uuid] if (!result) { continue } if (result.result === 'drop') { drops.push({ uuid: event.uuid, details: result.details ?? undefined }) } else if (result.result === 'retry') { retryable.push(event) } // ok / warning / unknown codes are terminal successes. } return retryable } private backoffDelay(attempt: number, retryAfterMs?: number): number { const exponential = Math.min(this.config.initialRetryDelayMs * 2 ** (attempt - 1), this.maxBackoffMs) if (retryAfterMs === undefined) { return exponential } // Retry-After is a minimum (never lets us retry earlier than our own backoff) and is clamped to the ceiling. return Math.max(exponential, Math.min(retryAfterMs, this.maxBackoffMs)) } /** Parse `Retry-After` (delta-seconds or HTTP-date). Non-positive/past values are ignored. */ private parseRetryAfter(response: PostHogFetchResponse): number | undefined { const raw = response.headers?.get('Retry-After') if (!raw) { return undefined } const trimmed = raw.trim() if (/^\d+$/.test(trimmed)) { const seconds = parseInt(trimmed, 10) return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined } const dateMs = Date.parse(trimmed) if (Number.isNaN(dateMs)) { return undefined } const delta = dateMs - this.now() return delta > 0 ? delta : undefined } private async parseResponse(response: PostHogFetchResponse): Promise { const text = await response.text() const parsed = JSON.parse(text) if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error('unexpected response shape') } const results = (parsed as { results?: unknown }).results if (results !== undefined && (typeof results !== 'object' || results === null || Array.isArray(results))) { throw new Error('unexpected results shape') } return { results: (results as V1BatchResponse['results']) ?? {} } } private async buildHttpError( response: PostHogFetchResponse, status: number, waitFor: V1CaptureAttempt['waitFor'] ): Promise { let bodyText = '' try { bodyText = (await waitFor(response.text())).slice(0, 512) } catch { // best-effort; a missing or timed-out body should not mask the status } const suffix = bodyText ? `: ${bodyText}` : '' return new Error(`Capture V1 request failed with HTTP ${status}${suffix}`) } private surfaceBatchFailure(requestId: string, drops: V1DroppedEvent[], pending: V1Event[], cause: unknown): void { this.onError(new CaptureV1Error({ requestId, drops, retryExhausted: pending.map((event) => event.uuid), cause })) } private surfacePartialDrops(requestId: string, drops: V1DroppedEvent[]): void { if (drops.length > 0) { this.onError(new CaptureV1Error({ requestId, drops, retryExhausted: [] })) } } private async cancelBody(response: PostHogFetchResponse): Promise { try { await response.body?.cancel() } catch { // Cancellation is best-effort and must never mask delivery handling. } } }