From 60e5811b0cb6d9c66997b7d2443731cfac6ec76d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 14 May 2026 13:39:37 -0400 Subject: [PATCH 01/17] feat: use batch ingestion for browser analytics requests --- .changeset/quiet-browsers-batch.md | 5 ++ .../browser/functional_tests/mock-server.ts | 33 ++++++++---- .../browser/src/__tests__/cookieless.test.ts | 6 +-- .../exception-observer.test.ts | 2 +- .../src/__tests__/posthog-core-also.test.ts | 46 +++++++++++++++- .../browser/src/__tests__/request.test.ts | 20 +++++++ packages/browser/src/posthog-core.ts | 29 ++++++++-- packages/browser/src/request.ts | 53 ++++++++++++++----- packages/browser/src/types.ts | 5 ++ 9 files changed, 165 insertions(+), 34 deletions(-) create mode 100644 .changeset/quiet-browsers-batch.md diff --git a/.changeset/quiet-browsers-batch.md b/.changeset/quiet-browsers-batch.md new file mode 100644 index 0000000000..56f16999f3 --- /dev/null +++ b/.changeset/quiet-browsers-batch.md @@ -0,0 +1,5 @@ +--- +'posthog-js': minor +--- + +Send regular browser analytics requests through batch ingestion with HTTP gzip encoding while preserving legacy sendBeacon delivery. diff --git a/packages/browser/functional_tests/mock-server.ts b/packages/browser/functional_tests/mock-server.ts index 15d74d3411..2317dadc59 100644 --- a/packages/browser/functional_tests/mock-server.ts +++ b/packages/browser/functional_tests/mock-server.ts @@ -5,6 +5,7 @@ import { setupServer } from 'msw/node' import { RestContext } from 'msw' import { RestRequest } from 'msw' import { decompressSync, strFromU8 } from 'fflate' +import { isArray } from '@posthog/core' // the request bodies in a store that we can inspect within tests. const capturedRequests: { '/e/': any[]; '/engage/': any[]; '/flags/': any[] } = { @@ -16,25 +17,32 @@ const capturedRequests: { '/e/': any[]; '/engage/': any[]; '/flags/': any[] } = const handleRequest = (group: string) => (req: RestRequest, res: ResponseComposition, ctx: RestContext) => { let body = req.body - if (typeof body === 'string') { - try { - const b64Encoded = req.url.href.includes('compression=base64') - const gzipCompressed = req.url.href.includes('compression=gzip-js') + try { + const b64Encoded = req.url.href.includes('compression=base64') + const gzipCompressed = + req.url.href.includes('compression=gzip-js') || req.headers.get('content-encoding') === 'gzip' + if (gzipCompressed) { + const data = new Uint8Array(req._body) + const decoded = strFromU8(decompressSync(data)) + body = JSON.parse(decoded) + } else if (typeof body === 'string') { if (b64Encoded) { body = JSON.parse(Buffer.from(decodeURIComponent(body.split('=')[1]), 'base64').toString()) - } else if (gzipCompressed) { - const data = new Uint8Array(req._body) - const decoded = strFromU8(decompressSync(data)) - body = JSON.parse(decoded) + } else if (body[0] === '{' || body[0] === '[') { + body = JSON.parse(body) } else { body = JSON.parse(decodeURIComponent(body.split('=')[1])) } - } catch { - return res(ctx.status(500)) } + } catch { + return res(ctx.status(500)) } - capturedRequests[group] = [...(capturedRequests[group] || []), body] + if (group === '/batch/' && body && typeof body === 'object' && isArray(body.batch)) { + capturedRequests['/e/'] = [...capturedRequests['/e/'], ...body.batch] + } else { + capturedRequests[group] = [...(capturedRequests[group] || []), body] + } return res(ctx.json({})) } @@ -43,6 +51,9 @@ const server = setupServer( rest.post('http://localhost/e/', (req, res, ctx) => { return handleRequest('/e/')(req, res, ctx) }), + rest.post('http://localhost/batch/', (req, res, ctx) => { + return handleRequest('/batch/')(req, res, ctx) + }), rest.post('http://localhost/engage/', (req, res, ctx) => { return handleRequest('/engage/')(req, res, ctx) }), diff --git a/packages/browser/src/__tests__/cookieless.test.ts b/packages/browser/src/__tests__/cookieless.test.ts index b2c9e529c7..66dc57db8e 100644 --- a/packages/browser/src/__tests__/cookieless.test.ts +++ b/packages/browser/src/__tests__/cookieless.test.ts @@ -374,13 +374,13 @@ describe('cookieless', () => { posthog.opt_in_capturing() expect(mockedFetch).toBeCalledTimes(3) // flags + opt in + pageview - expect(JSON.parse(mockedFetch.mock.calls[1][1].body).event).toEqual('$opt_in') - expect(JSON.parse(mockedFetch.mock.calls[2][1].body).event).toEqual('$pageview') + expect(JSON.parse(mockedFetch.mock.calls[1][1].body).batch[0].event).toEqual('$opt_in') + expect(JSON.parse(mockedFetch.mock.calls[2][1].body).batch[0].event).toEqual('$pageview') posthog.capture('custom event') jest.advanceTimersByTime(5000) // flush the batch queue (3s interval) without triggering 5-min remote config refresh expect(mockedFetch).toBeCalledTimes(4) // flags + opt in + pageview + custom event - expect(JSON.parse(mockedFetch.mock.calls[3][1].body)[0].event).toEqual('custom event') + expect(JSON.parse(mockedFetch.mock.calls[3][1].body).batch[0].event).toEqual('custom event') }) }) }) diff --git a/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts b/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts index cffc38da15..7757e2f310 100644 --- a/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts +++ b/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts @@ -146,7 +146,7 @@ describe('Exception Observer', () => { expect(sendRequestSpy).toHaveBeenCalled() const request = sendRequestSpy.mock.calls[0][0] - expect(request.url).toBe('http://localhost/e/?ip=0') + expect(request.url).toBe('http://localhost/e/') expect(request.data).toMatchObject({ event: '$exception', properties: { diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index bc19952194..1cb1b91f07 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -6,8 +6,9 @@ import { uuidv7 } from '../uuidv7' import { isUndefined } from '@posthog/core' import { ENABLE_PERSON_PROCESSING, USER_STATE } from '../constants' import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance' -import { PostHogConfig, RemoteConfig } from '../types' +import { Compression, PostHogConfig, RemoteConfig } from '../types' import { PostHog } from '../posthog-core' +import * as requestModule from '../request' import { PostHogPersistence } from '../posthog-persistence' import { SessionIdManager } from '../sessionid' import { RequestQueue } from '../request-queue' @@ -351,6 +352,49 @@ describe('posthog core', () => { ) }) + it('sends non-beacon analytics requests to /batch/ with HTTP gzip semantics', () => { + const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) + const posthog = posthogWith({ ...defaultConfig, request_batching: false }) + posthog._onRemoteConfig({ supportedCompression: [Compression.GZipJS] } as RemoteConfig) + requestSpy.mockClear() + + posthog.capture('event-name', { foo: 'bar', length: 0 }) + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://us.i.posthog.com/batch/?ip=0', + compression: Compression.GZipJS, + _compressionEncoding: 'content-encoding', + data: expect.objectContaining({ + api_key: 'testtoken', + sent_at: '2020-01-01T00:00:00.000Z', + batch: [expect.objectContaining({ event: 'event-name' })], + }), + }) + ) + requestSpy.mockRestore() + }) + + it('keeps sendBeacon analytics requests on the legacy /e/ protocol', () => { + const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) + const posthog = posthogWith({ ...defaultConfig, request_batching: false }) + posthog._onRemoteConfig({ supportedCompression: [Compression.GZipJS] } as RemoteConfig) + requestSpy.mockClear() + + posthog.capture('event-name', { foo: 'bar', length: 0 }, { transport: 'sendBeacon' } as any) + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://us.i.posthog.com/e/?ip=0', + compression: Compression.GZipJS, + data: expect.objectContaining({ event: 'event-name' }), + transport: 'sendBeacon', + }) + ) + expect(requestSpy.mock.calls[0][0]).not.toHaveProperty('_compressionEncoding') + requestSpy.mockRestore() + }) + it('does not allow you to set complex current url', () => { const posthog = posthogWith(defaultConfig, defaultOverrides) const captureResult = posthog.capture('event-name', { $current_url: new URL('https://app.posthog.com/s/') }) diff --git a/packages/browser/src/__tests__/request.test.ts b/packages/browser/src/__tests__/request.test.ts index 2ce7f76823..ea636c4f64 100644 --- a/packages/browser/src/__tests__/request.test.ts +++ b/packages/browser/src/__tests__/request.test.ts @@ -469,12 +469,32 @@ describe('request', () => { " `) + expect(mockedXHR.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'text/plain') + expect(mockedXHR.setRequestHeader).not.toHaveBeenCalledWith('Content-Encoding', 'gzip') expect(mockedXHR.setRequestHeader).not.toHaveBeenCalledWith( 'Content-Type', 'application/x-www-form-urlencoded' ) }) + it('should use HTTP content encoding for gzip when requested', async () => { + request( + createRequest({ + url: 'https://any.posthog-instance.com/', + method: 'POST', + compression: Compression.GZipJS, + _compressionEncoding: 'content-encoding', + data: { foo: 'bar' }, + } as any) + ) + + expect(mockedXHR.open.mock.calls[0][1]).not.toContain('compression=gzip-js') + expect(mockedXHR.send).toHaveBeenCalledTimes(1) + expect(mockedXHR.send.mock.calls[0][0]).toBeInstanceOf(ArrayBuffer) + expect(mockedXHR.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'application/json') + expect(mockedXHR.setRequestHeader).toHaveBeenCalledWith('Content-Encoding', 'gzip') + }) + it('converts bigint properties to string without throwing', () => { request( createRequest({ diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index cd908dbdf3..0200dffb45 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -38,7 +38,7 @@ import { import { ProductTourEventName, ProductTourEventProperties } from './posthog-product-tours-types' import { RateLimiter } from './rate-limiter' import { RemoteConfigLoader } from './remote-config' -import { extendURLParams, request, SUPPORTS_REQUEST } from './request' +import { COMPRESSION_ENCODING_CONTENT_ENCODING, extendURLParams, request, SUPPORTS_REQUEST } from './request' import { DEFAULT_FLUSH_INTERVAL_MS, RequestQueue } from './request-queue' import { RetryQueue } from './retry-queue' import { ScrollManager } from './scroll-manager' @@ -108,6 +108,7 @@ import { isEmptyObject, isObject, isBoolean, + currentISOTime, } from '@posthog/core' import { uuidv7 } from './uuidv7' import { ExternalIntegrations } from './extensions/external-integration' @@ -158,6 +159,8 @@ const instances: Record = {} // calls into push() calls, which would otherwise cause infinite recursion. let _executeArrayDepth = 0 +const COMPRESSION_BEST_AVAILABLE = 'best-available' + const __NOOP = () => {} const CONSENT_COOKIELESS_WARN = 'Consent opt in/out is not valid with cookieless_mode="always" and will be ignored' const SURVEYS_NOT_AVAILABLE = 'Surveys module not available' @@ -1010,7 +1013,9 @@ export class PostHog implements PostHogInterface { this._retryQueue?.unload() } - _send_request(options: QueuedRequestWithOptions): void { + _send_request(_options: QueuedRequestWithOptions): void { + const options = { ..._options } + if (!this.__loaded) { return } @@ -1025,6 +1030,22 @@ export class PostHog implements PostHogInterface { } options.transport = options.transport || this.config.api_transport + options.compression = + options.compression === COMPRESSION_BEST_AVAILABLE ? this.compression : options.compression + + const isAnalyticsRequest = options.url === this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) + if (isAnalyticsRequest && options.transport !== 'sendBeacon' && options.data) { + options.url = this.requestRouter.endpointFor('api', '/batch/') + options.data = { + api_key: this.config.token, + batch: isArray(options.data) ? options.data : [options.data], + sent_at: currentISOTime(), + } + options._compressionEncoding = + options.compression === Compression.GZipJS ? COMPRESSION_ENCODING_CONTENT_ENCODING : undefined + options.compression = options.compression === Compression.GZipJS ? Compression.GZipJS : undefined + } + options.url = extendURLParams(options.url, { // Whether to detect ip info or not ip: this.config.ip ? 1 : 0, @@ -1033,7 +1054,6 @@ export class PostHog implements PostHogInterface { ...this.config.request_headers, ...options.headers, } - options.compression = options.compression === 'best-available' ? this.compression : options.compression options.disableXHRCredentials = this.config.__preview_disable_xhr_credentials if (this.config.__preview_disable_beacon) { options.disableTransport = ['sendBeacon'] @@ -1362,8 +1382,9 @@ export class PostHog implements PostHogInterface { method: 'POST', url: options?._url ?? this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint), data, - compression: 'best-available', + compression: COMPRESSION_BEST_AVAILABLE, batchKey: options?._batchKey, + transport: options?.transport, } if (this.config.request_batching && (!options || options?._batchKey) && !options?.send_instantly) { diff --git a/packages/browser/src/request.ts b/packages/browser/src/request.ts index b5ccb178ab..f166faed8a 100644 --- a/packages/browser/src/request.ts +++ b/packages/browser/src/request.ts @@ -26,6 +26,8 @@ export const SUPPORTS_REQUEST = !!XMLHttpRequest || !!fetch const CONTENT_TYPE_PLAIN = 'text/plain' const CONTENT_TYPE_JSON = 'application/json' const CONTENT_TYPE_FORM = 'application/x-www-form-urlencoded' +const CONTENT_ENCODING_GZIP = 'gzip' +export const COMPRESSION_ENCODING_CONTENT_ENCODING = 'content-encoding' const SIXTY_FOUR_KILOBYTES = 64 * 1024 /* fetch will fail if we request keepalive with a body greater than 64kb @@ -53,6 +55,7 @@ const removeURLParam = (url: string, param: string): string => { type EncodedBody = { contentType: string + contentEncoding?: string body: string | BlobPart | ArrayBuffer estimatedSize: number } @@ -62,6 +65,10 @@ type EncodedRequest = { encodedBody?: EncodedBody } +const useHTTPContentEncoding = (options: RequestWithEncodedBody): boolean => { + return options._compressionEncoding === COMPRESSION_ENCODING_CONTENT_ENCODING +} + /** * Extends a URL with additional query parameters * @param url - The URL to extend @@ -115,8 +122,10 @@ const encodePostData = (options: RequestWithEncodedBody): EncodedBody | undefine if (compression === Compression.GZipJS) { const gzipData = gzipSync(strToU8(jsonStringify(data)), { mtime: 0 }) + const useContentEncoding = useHTTPContentEncoding(options) return { - contentType: CONTENT_TYPE_PLAIN, + contentType: useContentEncoding ? CONTENT_TYPE_JSON : CONTENT_TYPE_PLAIN, + contentEncoding: useContentEncoding ? CONTENT_ENCODING_GZIP : undefined, body: gzipData.buffer.slice(gzipData.byteOffset, gzipData.byteOffset + gzipData.byteLength) as ArrayBuffer, estimatedSize: gzipData.byteLength, } @@ -183,7 +192,8 @@ const preEncodeAsync = async (options: RequestWithEncodedBody): Promise { const req = new XMLHttpRequest!() const { url, encodedBody } = encodePostDataSafely(options) req.open(options.method || 'GET', url, true) - const { contentType, body } = encodedBody ?? {} + const { contentType, contentEncoding, body } = encodedBody ?? {} each(options.headers, function (headerValue, headerName) { req.setRequestHeader(headerName, headerValue) @@ -204,6 +214,10 @@ const xhr = (options: RequestWithOptions) => { req.setRequestHeader('Content-Type', contentType) } + if (contentEncoding) { + req.setRequestHeader('Content-Encoding', contentEncoding) + } + if (options.timeout) { req.timeout = options.timeout } @@ -235,7 +249,7 @@ const xhr = (options: RequestWithOptions) => { const _fetch = (options: RequestWithOptions) => { const { url, encodedBody } = encodePostDataSafely(options) - const { contentType, body, estimatedSize } = encodedBody ?? {} + const { contentType, contentEncoding, body, estimatedSize } = encodedBody ?? {} // eslint-disable-next-line compat/compat const headers = new Headers() @@ -247,6 +261,10 @@ const _fetch = (options: RequestWithOptions) => { headers.append('Content-Type', contentType) } + if (contentEncoding) { + headers.append('Content-Encoding', contentEncoding) + } + let aborter: { signal: any; timeout: ReturnType } | null = null if (AbortController) { @@ -323,11 +341,11 @@ const _sendBeacon = (options: RequestWithOptions) => { } } -const buildRequestURL = (url: string, compression?: RequestWithOptions['compression']): string => { +const buildRequestURL = (url: string, options: RequestWithEncodedBody): string => { return extendURLParams(url, { _: new Date().getTime().toString(), ver: Config.JS_SDK_VERSION, - compression, + compression: useHTTPContentEncoding(options) ? undefined : options.compression, }) } @@ -364,26 +382,29 @@ export const request = (_options: RequestWithOptions) => { const options: RequestWithEncodedBody = { ..._options } options.timeout = options.timeout || 60000 - options.url = buildRequestURL(options.url, options.compression) - - const transport = options.transport ?? 'fetch' + const requestedTransport = options.transport ?? 'fetch' const availableTransports = AVAILABLE_TRANSPORTS.filter( (t) => !options.disableTransport || !t.transport || !options.disableTransport.includes(t.transport) ) - const transportMethod = - find(availableTransports, (t) => t.transport === transport)?.method ?? availableTransports[0].method + const transport = find(availableTransports, (t) => t.transport === requestedTransport) ?? availableTransports[0] + const transportMethod = transport?.method if (!transportMethod) { throw new Error('No available transport method') } + if (transport.transport === 'sendBeacon') { + options._compressionEncoding = undefined + } + options.url = buildRequestURL(options.url, options) + // For non-sendBeacon transports, use async native CompressionStream when available // to avoid blocking the main thread with fflate's synchronous gzip (which can take 300ms+). // sendBeacon must remain synchronous as it's used during page unload. if ( - transport !== 'sendBeacon' && + transport.transport !== 'sendBeacon' && options.data && options.compression === Compression.GZipJS && !!CompressionStream && @@ -396,10 +417,14 @@ export const request = (_options: RequestWithOptions) => { .catch((error) => { if (isNativeAsyncGzipReadError(error)) { nativeAsyncGzipDisabled = true - transportMethod({ + const uncompressedOptions = { ...options, compression: undefined, - url: buildRequestURL(_options.url, undefined), + _compressionEncoding: undefined, + } + transportMethod({ + ...uncompressedOptions, + url: buildRequestURL(_options.url, uncompressedOptions), }) return } diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 620e532b21..798b658355 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -192,6 +192,11 @@ export interface RequestWithOptions { disableTransport?: ('XHR' | 'fetch' | 'sendBeacon')[] disableXHRCredentials?: boolean compression?: Compression | 'best-available' + /** + * Internal: when set, gzip is signaled using the standard HTTP + * Content-Encoding header instead of PostHog's legacy query-param protocol. + */ + _compressionEncoding?: 'content-encoding' fetchOptions?: { cache?: RequestInit['cache'] next?: NextOptions From c844223a54a83da63cdb4ddf9bd296ab6a2608c5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 18 May 2026 18:16:52 +0200 Subject: [PATCH 02/17] fix browser batch retry review feedback --- .../src/__tests__/posthog-core-also.test.ts | 30 +++++++++++++++++-- .../browser/src/__tests__/request.test.ts | 2 +- packages/browser/src/posthog-core.ts | 12 ++++---- packages/browser/src/request.ts | 7 ++--- packages/browser/src/types.ts | 4 +-- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 1cb1b91f07..a34e643337 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -364,7 +364,7 @@ describe('posthog core', () => { expect.objectContaining({ url: 'https://us.i.posthog.com/batch/?ip=0', compression: Compression.GZipJS, - _compressionEncoding: 'content-encoding', + _useContentEncoding: true, data: expect.objectContaining({ api_key: 'testtoken', sent_at: '2020-01-01T00:00:00.000Z', @@ -375,6 +375,32 @@ describe('posthog core', () => { requestSpy.mockRestore() }) + it('sends retried analytics requests with query params to /batch/', () => { + const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) + const posthog = posthogWith({ ...defaultConfig, request_batching: false }) + posthog._onRemoteConfig({ supportedCompression: [Compression.GZipJS] } as RemoteConfig) + requestSpy.mockClear() + + posthog._send_request({ + url: 'https://us.i.posthog.com/e/?retry_count=1', + data: { event: 'event-name' }, + compression: Compression.GZipJS, + }) + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://us.i.posthog.com/batch/?retry_count=1&ip=0', + compression: Compression.GZipJS, + _useContentEncoding: true, + data: expect.objectContaining({ + api_key: 'testtoken', + batch: [expect.objectContaining({ event: 'event-name' })], + }), + }) + ) + requestSpy.mockRestore() + }) + it('keeps sendBeacon analytics requests on the legacy /e/ protocol', () => { const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) const posthog = posthogWith({ ...defaultConfig, request_batching: false }) @@ -391,7 +417,7 @@ describe('posthog core', () => { transport: 'sendBeacon', }) ) - expect(requestSpy.mock.calls[0][0]).not.toHaveProperty('_compressionEncoding') + expect(requestSpy.mock.calls[0][0]).not.toHaveProperty('_useContentEncoding') requestSpy.mockRestore() }) diff --git a/packages/browser/src/__tests__/request.test.ts b/packages/browser/src/__tests__/request.test.ts index ea636c4f64..3be2b8d938 100644 --- a/packages/browser/src/__tests__/request.test.ts +++ b/packages/browser/src/__tests__/request.test.ts @@ -483,7 +483,7 @@ describe('request', () => { url: 'https://any.posthog-instance.com/', method: 'POST', compression: Compression.GZipJS, - _compressionEncoding: 'content-encoding', + _useContentEncoding: true, data: { foo: 'bar' }, } as any) ) diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index 0200dffb45..174f0580ad 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -38,7 +38,7 @@ import { import { ProductTourEventName, ProductTourEventProperties } from './posthog-product-tours-types' import { RateLimiter } from './rate-limiter' import { RemoteConfigLoader } from './remote-config' -import { COMPRESSION_ENCODING_CONTENT_ENCODING, extendURLParams, request, SUPPORTS_REQUEST } from './request' +import { extendURLParams, request, SUPPORTS_REQUEST } from './request' import { DEFAULT_FLUSH_INTERVAL_MS, RequestQueue } from './request-queue' import { RetryQueue } from './retry-queue' import { ScrollManager } from './scroll-manager' @@ -1033,17 +1033,17 @@ export class PostHog implements PostHogInterface { options.compression = options.compression === COMPRESSION_BEST_AVAILABLE ? this.compression : options.compression - const isAnalyticsRequest = options.url === this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) + const analyticsEndpoint = this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) + const isAnalyticsRequest = options.url === analyticsEndpoint || options.url.startsWith(`${analyticsEndpoint}?`) if (isAnalyticsRequest && options.transport !== 'sendBeacon' && options.data) { - options.url = this.requestRouter.endpointFor('api', '/batch/') + options.url = options.url.replace(analyticsEndpoint, this.requestRouter.endpointFor('api', '/batch/')) options.data = { api_key: this.config.token, batch: isArray(options.data) ? options.data : [options.data], sent_at: currentISOTime(), } - options._compressionEncoding = - options.compression === Compression.GZipJS ? COMPRESSION_ENCODING_CONTENT_ENCODING : undefined - options.compression = options.compression === Compression.GZipJS ? Compression.GZipJS : undefined + options._useContentEncoding = options.compression === Compression.GZipJS + options.compression = options.compression === Compression.GZipJS ? options.compression : undefined } options.url = extendURLParams(options.url, { diff --git a/packages/browser/src/request.ts b/packages/browser/src/request.ts index f166faed8a..e173d97a49 100644 --- a/packages/browser/src/request.ts +++ b/packages/browser/src/request.ts @@ -27,7 +27,6 @@ const CONTENT_TYPE_PLAIN = 'text/plain' const CONTENT_TYPE_JSON = 'application/json' const CONTENT_TYPE_FORM = 'application/x-www-form-urlencoded' const CONTENT_ENCODING_GZIP = 'gzip' -export const COMPRESSION_ENCODING_CONTENT_ENCODING = 'content-encoding' const SIXTY_FOUR_KILOBYTES = 64 * 1024 /* fetch will fail if we request keepalive with a body greater than 64kb @@ -66,7 +65,7 @@ type EncodedRequest = { } const useHTTPContentEncoding = (options: RequestWithEncodedBody): boolean => { - return options._compressionEncoding === COMPRESSION_ENCODING_CONTENT_ENCODING + return !!options._useContentEncoding } /** @@ -396,7 +395,7 @@ export const request = (_options: RequestWithOptions) => { } if (transport.transport === 'sendBeacon') { - options._compressionEncoding = undefined + options._useContentEncoding = undefined } options.url = buildRequestURL(options.url, options) @@ -420,7 +419,7 @@ export const request = (_options: RequestWithOptions) => { const uncompressedOptions = { ...options, compression: undefined, - _compressionEncoding: undefined, + _useContentEncoding: undefined, } transportMethod({ ...uncompressedOptions, diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 798b658355..fb43b1a6df 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -193,10 +193,10 @@ export interface RequestWithOptions { disableXHRCredentials?: boolean compression?: Compression | 'best-available' /** - * Internal: when set, gzip is signaled using the standard HTTP + * Internal: when true, gzip is signaled using the standard HTTP * Content-Encoding header instead of PostHog's legacy query-param protocol. */ - _compressionEncoding?: 'content-encoding' + _useContentEncoding?: boolean fetchOptions?: { cache?: RequestInit['cache'] next?: NextOptions From 878638793c304af26e1c9e7ddad9ced6c588ceca Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 18 May 2026 18:27:56 +0200 Subject: [PATCH 03/17] omit unused query params for browser batch requests --- .../src/__tests__/posthog-core-also.test.ts | 4 ++-- packages/browser/src/posthog-core.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index a34e643337..47043b535d 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -362,7 +362,7 @@ describe('posthog core', () => { expect(requestSpy).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://us.i.posthog.com/batch/?ip=0', + url: 'https://us.i.posthog.com/batch/', compression: Compression.GZipJS, _useContentEncoding: true, data: expect.objectContaining({ @@ -389,7 +389,7 @@ describe('posthog core', () => { expect(requestSpy).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://us.i.posthog.com/batch/?retry_count=1&ip=0', + url: 'https://us.i.posthog.com/batch/', compression: Compression.GZipJS, _useContentEncoding: true, data: expect.objectContaining({ diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index 174f0580ad..2525cd9c37 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -1035,8 +1035,10 @@ export class PostHog implements PostHogInterface { const analyticsEndpoint = this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) const isAnalyticsRequest = options.url === analyticsEndpoint || options.url.startsWith(`${analyticsEndpoint}?`) + let isBatchAnalyticsRequest = false if (isAnalyticsRequest && options.transport !== 'sendBeacon' && options.data) { - options.url = options.url.replace(analyticsEndpoint, this.requestRouter.endpointFor('api', '/batch/')) + options.url = this.requestRouter.endpointFor('api', '/batch/') + isBatchAnalyticsRequest = true options.data = { api_key: this.config.token, batch: isArray(options.data) ? options.data : [options.data], @@ -1046,10 +1048,12 @@ export class PostHog implements PostHogInterface { options.compression = options.compression === Compression.GZipJS ? options.compression : undefined } - options.url = extendURLParams(options.url, { - // Whether to detect ip info or not - ip: this.config.ip ? 1 : 0, - }) + if (!isBatchAnalyticsRequest) { + options.url = extendURLParams(options.url, { + // Whether to detect ip info or not + ip: this.config.ip ? 1 : 0, + }) + } options.headers = { ...this.config.request_headers, ...options.headers, From fca8c78742d6cbc54be0022c3b9f9df483e999f1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 10:38:33 +0200 Subject: [PATCH 04/17] route beacon analytics requests through batch --- .../browser/src/__tests__/posthog-core-also.test.ts | 11 +++++++---- packages/browser/src/posthog-core.ts | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 47043b535d..62c3dd21f3 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -401,7 +401,7 @@ describe('posthog core', () => { requestSpy.mockRestore() }) - it('keeps sendBeacon analytics requests on the legacy /e/ protocol', () => { + it('sends sendBeacon analytics requests to /batch/', () => { const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) const posthog = posthogWith({ ...defaultConfig, request_batching: false }) posthog._onRemoteConfig({ supportedCompression: [Compression.GZipJS] } as RemoteConfig) @@ -411,13 +411,16 @@ describe('posthog core', () => { expect(requestSpy).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://us.i.posthog.com/e/?ip=0', + url: 'https://us.i.posthog.com/batch/', compression: Compression.GZipJS, - data: expect.objectContaining({ event: 'event-name' }), + _useContentEncoding: true, + data: expect.objectContaining({ + api_key: 'testtoken', + batch: [expect.objectContaining({ event: 'event-name' })], + }), transport: 'sendBeacon', }) ) - expect(requestSpy.mock.calls[0][0]).not.toHaveProperty('_useContentEncoding') requestSpy.mockRestore() }) diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index 2525cd9c37..4237bb13f7 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -1036,7 +1036,7 @@ export class PostHog implements PostHogInterface { const analyticsEndpoint = this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) const isAnalyticsRequest = options.url === analyticsEndpoint || options.url.startsWith(`${analyticsEndpoint}?`) let isBatchAnalyticsRequest = false - if (isAnalyticsRequest && options.transport !== 'sendBeacon' && options.data) { + if (isAnalyticsRequest && options.data) { options.url = this.requestRouter.endpointFor('api', '/batch/') isBatchAnalyticsRequest = true options.data = { From 50258eb574b3736a4a3744fb27ccb7c6b6d489bf Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 10:44:30 +0200 Subject: [PATCH 05/17] default browser analytics endpoint to batch --- .../browser/src/__tests__/posthog-core-also.test.ts | 10 +++++----- packages/browser/src/posthog-core.ts | 2 +- packages/browser/src/types.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 62c3dd21f3..2aa6247d2e 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -302,14 +302,14 @@ describe('posthog core', () => { expect(captureResult.properties).toEqual(expect.objectContaining({ foo: 'bar', length: 0 })) }) - it('sends payloads to /e/ by default', () => { + it('sends payloads to /batch/ by default', () => { const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) posthog.capture('event-name', { foo: 'bar', length: 0 }) expect(posthog._send_request).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://us.i.posthog.com/e/', + url: 'https://us.i.posthog.com/batch/', }) ) }) @@ -382,7 +382,7 @@ describe('posthog core', () => { requestSpy.mockClear() posthog._send_request({ - url: 'https://us.i.posthog.com/e/?retry_count=1', + url: 'https://us.i.posthog.com/batch/?retry_count=1', data: { event: 'event-name' }, compression: Compression.GZipJS, }) @@ -474,12 +474,12 @@ describe('posthog core', () => { expect(posthog.compression).toEqual(undefined) }) - it('defaults to /e if no endpoint is given', () => { + it('defaults to /batch if no endpoint is given', () => { const posthog = posthogWith({}) posthog._onRemoteConfig({} as RemoteConfig) - expect(posthog.analyticsDefaultEndpoint).toEqual('/e/') + expect(posthog.analyticsDefaultEndpoint).toEqual('/batch/') }) it('uses the specified analytics endpoint if given', () => { diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index 4237bb13f7..d1ff789131 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -450,7 +450,7 @@ export class PostHog implements PostHogInterface { this.sentryIntegration = (options?: SentryIntegrationOptions) => sentryIntegration(this, options) this.__request_queue = [] this.__loaded = false - this.analyticsDefaultEndpoint = '/e/' + this.analyticsDefaultEndpoint = '/batch/' this._initialPageviewCaptured = false this._visibilityStateListener = null this._initialPersonProfilesConfig = null diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index fb43b1a6df..0e9020731d 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -311,7 +311,7 @@ export interface RemoteConfig { /** * Whether we should use a custom endpoint for analytics * - * @default { endpoint: "/e" } + * @default { endpoint: "/batch" } */ analytics?: { endpoint?: string From 1df1f02bde6a2b9693a5efb2dbce2f21e7bdd93b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 10:53:11 +0200 Subject: [PATCH 06/17] remove deprecated ip query param --- .../src/__tests__/extensions/replay/config.test.ts | 12 ++++++------ packages/browser/src/posthog-core.ts | 10 +--------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/browser/src/__tests__/extensions/replay/config.test.ts b/packages/browser/src/__tests__/extensions/replay/config.test.ts index 100dec5520..2977389ce5 100644 --- a/packages/browser/src/__tests__/extensions/replay/config.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/config.test.ts @@ -79,21 +79,21 @@ describe('config', () => { ], [ { - name: 'https://app.posthog.com/s/?ip=0&ver=123', + name: 'https://app.posthog.com/s/?ver=123', }, undefined, undefined, ], [ { - name: 'https://app.posthog.com/e/?ip=0&ver=123', + name: 'https://app.posthog.com/e/?ver=123', }, undefined, undefined, ], [ { - name: 'https://app.posthog.com/i/v0/e/?ip=0&ver=123', + name: 'https://app.posthog.com/i/v0/e/?ver=123', }, undefined, undefined, @@ -101,7 +101,7 @@ describe('config', () => { [ { // even an imaginary future world of rust session replay capture - name: 'https://app.posthog.com/i/v0/s/?ip=0&ver=123', + name: 'https://app.posthog.com/i/v0/s/?ver=123', }, undefined, undefined, @@ -109,7 +109,7 @@ describe('config', () => { [ { // using a relative path as a reverse proxy api host - name: 'https://app.posthog.com/ingest/s/?ip=0&ver=123', + name: 'https://app.posthog.com/ingest/s/?ver=123', }, undefined, '/ingest', @@ -117,7 +117,7 @@ describe('config', () => { [ { // using a reverse proxy with a path - name: 'https://app.posthog.com/ingest/s/?ip=0&ver=123', + name: 'https://app.posthog.com/ingest/s/?ver=123', }, undefined, 'https://app.posthog.com/ingest', diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index d1ff789131..c143b24169 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -38,7 +38,7 @@ import { import { ProductTourEventName, ProductTourEventProperties } from './posthog-product-tours-types' import { RateLimiter } from './rate-limiter' import { RemoteConfigLoader } from './remote-config' -import { extendURLParams, request, SUPPORTS_REQUEST } from './request' +import { request, SUPPORTS_REQUEST } from './request' import { DEFAULT_FLUSH_INTERVAL_MS, RequestQueue } from './request-queue' import { RetryQueue } from './retry-queue' import { ScrollManager } from './scroll-manager' @@ -1035,10 +1035,8 @@ export class PostHog implements PostHogInterface { const analyticsEndpoint = this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) const isAnalyticsRequest = options.url === analyticsEndpoint || options.url.startsWith(`${analyticsEndpoint}?`) - let isBatchAnalyticsRequest = false if (isAnalyticsRequest && options.data) { options.url = this.requestRouter.endpointFor('api', '/batch/') - isBatchAnalyticsRequest = true options.data = { api_key: this.config.token, batch: isArray(options.data) ? options.data : [options.data], @@ -1048,12 +1046,6 @@ export class PostHog implements PostHogInterface { options.compression = options.compression === Compression.GZipJS ? options.compression : undefined } - if (!isBatchAnalyticsRequest) { - options.url = extendURLParams(options.url, { - // Whether to detect ip info or not - ip: this.config.ip ? 1 : 0, - }) - } options.headers = { ...this.config.request_headers, ...options.headers, From 7daa329ff40629e319668bd1b20cdb1d7b1bbb47 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 10:59:05 +0200 Subject: [PATCH 07/17] omit timestamp query param for analytics batches --- packages/browser/src/__tests__/request.test.ts | 16 ++++++++++++++++ packages/browser/src/posthog-core.ts | 3 +++ packages/browser/src/request.ts | 2 +- packages/browser/src/types.ts | 5 +++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/__tests__/request.test.ts b/packages/browser/src/__tests__/request.test.ts index 3be2b8d938..091f39cecb 100644 --- a/packages/browser/src/__tests__/request.test.ts +++ b/packages/browser/src/__tests__/request.test.ts @@ -598,6 +598,22 @@ describe('request', () => { `) }) + it('should omit timestamp query param when requested', () => { + request( + createRequest({ + url: 'https://any.posthog-instance.com/', + method: 'POST', + data: { foo: 'bar' }, + _skipTimestampQueryParam: true, + } as any) + ) + + expect(mockedNavigator?.sendBeacon).toHaveBeenCalledWith( + 'https://any.posthog-instance.com/?ver=1.23.45&beacon=1', + expect.any(Blob) + ) + }) + it('should not call sendBeacon when body is undefined', () => { request( createRequest({ diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index c143b24169..fa206d3a63 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -1043,6 +1043,9 @@ export class PostHog implements PostHogInterface { sent_at: currentISOTime(), } options._useContentEncoding = options.compression === Compression.GZipJS + // /batch/ carries sent_at in the JSON body. The backend prefers that value over the legacy `_` + // query param, so don't send redundant cache-buster timestamp metadata for analytics batches. + options._skipTimestampQueryParam = true options.compression = options.compression === Compression.GZipJS ? options.compression : undefined } diff --git a/packages/browser/src/request.ts b/packages/browser/src/request.ts index e173d97a49..d0549b7c70 100644 --- a/packages/browser/src/request.ts +++ b/packages/browser/src/request.ts @@ -342,7 +342,7 @@ const _sendBeacon = (options: RequestWithOptions) => { const buildRequestURL = (url: string, options: RequestWithEncodedBody): string => { return extendURLParams(url, { - _: new Date().getTime().toString(), + _: options._skipTimestampQueryParam ? undefined : new Date().getTime().toString(), ver: Config.JS_SDK_VERSION, compression: useHTTPContentEncoding(options) ? undefined : options.compression, }) diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 0e9020731d..9b399c78b5 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -197,6 +197,11 @@ export interface RequestWithOptions { * Content-Encoding header instead of PostHog's legacy query-param protocol. */ _useContentEncoding?: boolean + /** + * Internal: when true, skips the `_` query parameter because the request body + * already carries equivalent timestamp metadata. + */ + _skipTimestampQueryParam?: boolean fetchOptions?: { cache?: RequestInit['cache'] next?: NextOptions From 441f598bf3cebea6d4c8dde8156169004b126866 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 11:11:12 +0200 Subject: [PATCH 08/17] honor configured analytics endpoint payload format --- .../src/__tests__/posthog-core-also.test.ts | 23 +++++++++++++++++++ packages/browser/src/posthog-core.ts | 8 +++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 2aa6247d2e..c2eef51112 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -327,6 +327,29 @@ describe('posthog core', () => { ) }) + it('sends legacy analytics payload shape when remote config selects a legacy endpoint', () => { + const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) + const posthog = posthogWith({ ...defaultConfig, request_batching: false }) + posthog._onRemoteConfig({ + analytics: { endpoint: '/i/v0/e/' }, + supportedCompression: [Compression.GZipJS], + } as RemoteConfig) + requestSpy.mockClear() + + posthog.capture('event-name', { foo: 'bar', length: 0 }) + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://us.i.posthog.com/i/v0/e/', + compression: Compression.GZipJS, + data: expect.objectContaining({ event: 'event-name' }), + }) + ) + expect(requestSpy.mock.calls[0][0]).not.toHaveProperty('_useContentEncoding') + expect(requestSpy.mock.calls[0][0]).not.toHaveProperty('_skipTimestampQueryParam') + requestSpy.mockRestore() + }) + it('sends payloads to overriden endpoint if given', () => { const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index fa206d3a63..e595f60345 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -1033,10 +1033,10 @@ export class PostHog implements PostHogInterface { options.compression = options.compression === COMPRESSION_BEST_AVAILABLE ? this.compression : options.compression - const analyticsEndpoint = this.requestRouter.endpointFor('api', this.analyticsDefaultEndpoint) - const isAnalyticsRequest = options.url === analyticsEndpoint || options.url.startsWith(`${analyticsEndpoint}?`) - if (isAnalyticsRequest && options.data) { - options.url = this.requestRouter.endpointFor('api', '/batch/') + const batchEndpoint = this.requestRouter.endpointFor('api', '/batch/') + const isBatchAnalyticsRequest = options.url === batchEndpoint || options.url.startsWith(`${batchEndpoint}?`) + if (isBatchAnalyticsRequest && options.data) { + options.url = batchEndpoint options.data = { api_key: this.config.token, batch: isArray(options.data) ? options.data : [options.data], From 61d361123d999556f3635b82ad980f8a438a9207 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 11:11:45 +0200 Subject: [PATCH 09/17] document remote analytics endpoint default --- packages/browser/src/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 9b399c78b5..d46a67677b 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -314,7 +314,10 @@ export interface RemoteConfig { capturePerformance?: boolean | PerformanceCaptureConfig /** - * Whether we should use a custom endpoint for analytics + * Whether we should use a custom endpoint for analytics. + * + * The SDK defaults to `/batch/`, but remote config may currently return + * `/i/v0/e/` for legacy analytics ingestion. * * @default { endpoint: "/batch" } */ From 4b502aa3f30a4c5086471445fa55cece47d5c65b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 11:27:06 +0200 Subject: [PATCH 10/17] update browser terser mangled names --- packages/browser/terser-mangled-names.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/browser/terser-mangled-names.json b/packages/browser/terser-mangled-names.json index 3c49c246da..12a6c5a176 100644 --- a/packages/browser/terser-mangled-names.json +++ b/packages/browser/terser-mangled-names.json @@ -535,6 +535,7 @@ "_showTicketList", "_shutdown", "_shutdownOnce", + "_skipTimestampQueryParam", "_sortSurveysByAppearanceDelay", "_spanContext", "_staleCacheRefreshTriggered", @@ -599,6 +600,7 @@ "_urlTriggerMatching", "_urlTriggerStatus", "_urlTriggers", + "_useContentEncoding", "_validateEmail", "_validateIdentifyId", "_validateSampleRate", From 77c40cd360385515ead62d4bd62b63385a5c5bef Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 11:28:11 +0200 Subject: [PATCH 11/17] update browser analytics endpoint test expectations --- .../exception-autocapture/exception-observer.test.ts | 2 +- .../src/__tests__/posthog-core.beforeSend.test.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts b/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts index 7757e2f310..b745c784a9 100644 --- a/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts +++ b/packages/browser/src/__tests__/extensions/exception-autocapture/exception-observer.test.ts @@ -146,7 +146,7 @@ describe('Exception Observer', () => { expect(sendRequestSpy).toHaveBeenCalled() const request = sendRequestSpy.mock.calls[0][0] - expect(request.url).toBe('http://localhost/e/') + expect(request.url).toBe('http://localhost/batch/') expect(request.data).toMatchObject({ event: '$exception', properties: { diff --git a/packages/browser/src/__tests__/posthog-core.beforeSend.test.ts b/packages/browser/src/__tests__/posthog-core.beforeSend.test.ts index 4d9641eebd..87a796e49d 100644 --- a/packages/browser/src/__tests__/posthog-core.beforeSend.test.ts +++ b/packages/browser/src/__tests__/posthog-core.beforeSend.test.ts @@ -72,7 +72,8 @@ describe('posthog core - before send', () => { compression: 'best-available', data: capturedData, method: 'POST', - url: 'https://us.i.posthog.com/e/', + transport: undefined, + url: 'https://us.i.posthog.com/batch/', }) }) @@ -108,7 +109,8 @@ describe('posthog core - before send', () => { compression: 'best-available', data: capturedData[0], method: 'POST', - url: 'https://us.i.posthog.com/e/', + transport: undefined, + url: 'https://us.i.posthog.com/batch/', }) }) @@ -130,7 +132,8 @@ describe('posthog core - before send', () => { compression: 'best-available', data: capturedData, method: 'POST', - url: 'https://us.i.posthog.com/e/', + transport: undefined, + url: 'https://us.i.posthog.com/batch/', }) }) @@ -152,7 +155,8 @@ describe('posthog core - before send', () => { compression: 'best-available', data: capturedData, method: 'POST', - url: 'https://us.i.posthog.com/e/', + transport: undefined, + url: 'https://us.i.posthog.com/batch/', }) expect(mockLogger.warn).toHaveBeenCalledWith( `Event '${eventName}' has no properties after beforeSend function, this is likely an error.` From 2f8153684ee7993758829f54eaf6a9b0cdbd9e95 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 11:46:59 +0200 Subject: [PATCH 12/17] fix sendBeacon content encoding test expectation --- .../src/__tests__/posthog-core-also.test.ts | 1 - packages/browser/src/__tests__/request.test.ts | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 3667a60476..fd2535397e 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -436,7 +436,6 @@ describe('posthog core', () => { expect.objectContaining({ url: 'https://us.i.posthog.com/batch/', compression: Compression.GZipJS, - _useContentEncoding: true, data: expect.objectContaining({ api_key: 'testtoken', batch: [expect.objectContaining({ event: 'event-name' })], diff --git a/packages/browser/src/__tests__/request.test.ts b/packages/browser/src/__tests__/request.test.ts index 091f39cecb..4787e1f9d6 100644 --- a/packages/browser/src/__tests__/request.test.ts +++ b/packages/browser/src/__tests__/request.test.ts @@ -614,6 +614,24 @@ describe('request', () => { ) }) + it('uses query-param compression for sendBeacon when content encoding is requested', () => { + request( + createRequest({ + url: 'https://any.posthog-instance.com/batch/', + method: 'POST', + compression: Compression.GZipJS, + data: { foo: 'bar' }, + _useContentEncoding: true, + _skipTimestampQueryParam: true, + } as any) + ) + + expect(mockedNavigator?.sendBeacon).toHaveBeenCalledWith( + 'https://any.posthog-instance.com/batch/?ver=1.23.45&compression=gzip-js&beacon=1', + expect.any(Blob) + ) + }) + it('should not call sendBeacon when body is undefined', () => { request( createRequest({ From 596de304bb9e14c8e6a965be44cd1963466fb3a8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 12:38:34 +0200 Subject: [PATCH 13/17] avoid startsWith for IE compatibility --- packages/browser/src/posthog-core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index c32561c3ea..66a92e1005 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -1035,7 +1035,7 @@ export class PostHog implements PostHogInterface { options.compression === COMPRESSION_BEST_AVAILABLE ? this.compression : options.compression const batchEndpoint = this.requestRouter.endpointFor('api', '/batch/') - const isBatchAnalyticsRequest = options.url === batchEndpoint || options.url.startsWith(`${batchEndpoint}?`) + const isBatchAnalyticsRequest = options.url === batchEndpoint || options.url.indexOf(`${batchEndpoint}?`) === 0 if (isBatchAnalyticsRequest && options.data) { options.url = batchEndpoint options.data = { From e3cc0bd8553517fe4fada900d180462b01d8619b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 12:59:41 +0200 Subject: [PATCH 14/17] chore: disallow startsWith in browser sdk --- .../external/components/RichContent.tsx | 26 +++++----- .../conversations/external/url-utils.ts | 2 +- .../product-tours/product-tours.tsx | 11 +++-- .../replay/external/network-plugin.ts | 6 ++- packages/browser/src/extensions/surveys.tsx | 2 +- .../surveys/surveys-extension-utils.tsx | 4 +- packages/browser/src/posthog-exceptions.ts | 2 +- packages/browser/src/posthog-surveys.ts | 2 +- .../no-browser-startswith.js | 43 +++++++++++++++++ .../no-browser-startswith.test.js | 48 +++++++++++++++++++ 10 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 tooling/eslint-plugin-posthog-js/no-browser-startswith.js create mode 100644 tooling/eslint-plugin-posthog-js/no-browser-startswith.test.js diff --git a/packages/browser/src/extensions/conversations/external/components/RichContent.tsx b/packages/browser/src/extensions/conversations/external/components/RichContent.tsx index e0e5bf82a9..d2196e1345 100644 --- a/packages/browser/src/extensions/conversations/external/components/RichContent.tsx +++ b/packages/browser/src/extensions/conversations/external/components/RichContent.tsx @@ -46,10 +46,10 @@ function sanitizeUrl(url: string): string | undefined { // Block dangerous protocols if ( - normalizedForCheck.startsWith('javascript:') || - normalizedForCheck.startsWith('vbscript:') || - normalizedForCheck.startsWith('data:') || - normalizedForCheck.startsWith('file:') + normalizedForCheck.indexOf('javascript:') === 0 || + normalizedForCheck.indexOf('vbscript:') === 0 || + normalizedForCheck.indexOf('data:') === 0 || + normalizedForCheck.indexOf('file:') === 0 ) { return undefined } @@ -57,14 +57,14 @@ function sanitizeUrl(url: string): string | undefined { // Allow relative URLs (check against trimmed URL, not normalized) // Note: We explicitly check for '//' first to block protocol-relative URLs (e.g., //evil.com) // which could be used to load content from attacker-controlled domains - if (trimmedUrl.startsWith('//')) { + if (trimmedUrl.indexOf('//') === 0) { return undefined } if ( - trimmedUrl.startsWith('/') || - trimmedUrl.startsWith('./') || - trimmedUrl.startsWith('../') || - trimmedUrl.startsWith('#') + trimmedUrl.indexOf('/') === 0 || + trimmedUrl.indexOf('./') === 0 || + trimmedUrl.indexOf('../') === 0 || + trimmedUrl.indexOf('#') === 0 ) { return trimmedUrl } @@ -72,10 +72,10 @@ function sanitizeUrl(url: string): string | undefined { // Allow safe absolute URLs const lowerUrl = trimmedUrl.toLowerCase() if ( - lowerUrl.startsWith('http://') || - lowerUrl.startsWith('https://') || - lowerUrl.startsWith('mailto:') || - lowerUrl.startsWith('tel:') + lowerUrl.indexOf('http://') === 0 || + lowerUrl.indexOf('https://') === 0 || + lowerUrl.indexOf('mailto:') === 0 || + lowerUrl.indexOf('tel:') === 0 ) { return trimmedUrl } diff --git a/packages/browser/src/extensions/conversations/external/url-utils.ts b/packages/browser/src/extensions/conversations/external/url-utils.ts index 622eb64c23..95419e5b12 100644 --- a/packages/browser/src/extensions/conversations/external/url-utils.ts +++ b/packages/browser/src/extensions/conversations/external/url-utils.ts @@ -36,7 +36,7 @@ export function isCurrentDomainAllowed(domains: string[] | undefined): boolean { return false } - if (allowedHostname.startsWith('*.')) { + if (allowedHostname.indexOf('*.') === 0) { const pattern = allowedHostname.slice(2) return currentHostname.endsWith(`.${pattern}`) || currentHostname === pattern } diff --git a/packages/browser/src/extensions/product-tours/product-tours.tsx b/packages/browser/src/extensions/product-tours/product-tours.tsx index ebc3889dfb..de610a7c08 100644 --- a/packages/browser/src/extensions/product-tours/product-tours.tsx +++ b/packages/browser/src/extensions/product-tours/product-tours.tsx @@ -969,7 +969,7 @@ export class ProductTourManager { return true } const isFeatureEnabled = !!this._instance.featureFlags?.isFeatureEnabled(flagKey, { - send_event: !flagKey.startsWith(PRODUCT_TOUR_TARGETING_FLAG_PREFIX), + send_event: flagKey.indexOf(PRODUCT_TOUR_TARGETING_FLAG_PREFIX) !== 0, }) let flagVariantCheck = true if (flagVariant) { @@ -1132,10 +1132,11 @@ export class ProductTourManager { for (let i = 0; i < storage.length; i++) { const key = storage.key(i) if ( - key?.startsWith(TOUR_SHOWN_KEY_PREFIX) || - key?.startsWith(TOUR_COMPLETED_KEY_PREFIX) || - key?.startsWith(TOUR_DISMISSED_KEY_PREFIX) || - key?.startsWith(LAST_SEEN_TOUR_DATE_KEY_PREFIX) + key && + (key.indexOf(TOUR_SHOWN_KEY_PREFIX) === 0 || + key.indexOf(TOUR_COMPLETED_KEY_PREFIX) === 0 || + key.indexOf(TOUR_DISMISSED_KEY_PREFIX) === 0 || + key.indexOf(LAST_SEEN_TOUR_DATE_KEY_PREFIX) === 0) ) { keysToRemove.push(key) } diff --git a/packages/browser/src/extensions/replay/external/network-plugin.ts b/packages/browser/src/extensions/replay/external/network-plugin.ts index e4b9dcfdbb..3458647561 100644 --- a/packages/browser/src/extensions/replay/external/network-plugin.ts +++ b/packages/browser/src/extensions/replay/external/network-plugin.ts @@ -134,7 +134,7 @@ export function shouldRecordBody({ function isBlobURL(url: string | URL | RequestInfo) { try { if (typeof url === 'string') { - return url.startsWith('blob:') + return url.indexOf('blob:') === 0 } if (url instanceof URL) { return url.protocol === 'blob:' @@ -476,7 +476,9 @@ function _checkForCannotReadResponseBody({ // `get` and `has` are case-insensitive // but return the header value with the casing that was supplied const contentType = r.headers.get('Content-Type')?.toLowerCase() - const contentTypeIsDenied = contentTypePrefixDenyList.some((prefix) => contentType?.startsWith(prefix)) + const contentTypeIsDenied = contentTypePrefixDenyList.some( + (prefix) => !!contentType && contentType.indexOf(prefix) === 0 + ) if (contentType && contentTypeIsDenied) { return `Content-Type ${contentType} is not supported` } diff --git a/packages/browser/src/extensions/surveys.tsx b/packages/browser/src/extensions/surveys.tsx index e02f25a6c8..88888fc298 100644 --- a/packages/browser/src/extensions/surveys.tsx +++ b/packages/browser/src/extensions/surveys.tsx @@ -556,7 +556,7 @@ export class SurveyManager { return true } const isFeatureEnabled = !!this._posthog.featureFlags?.isFeatureEnabled(flagKey, { - send_event: !flagKey.startsWith(SURVEY_TARGETING_FLAG_PREFIX), + send_event: flagKey.indexOf(SURVEY_TARGETING_FLAG_PREFIX) !== 0, }) let flagVariantCheck = true if (flagVariant) { diff --git a/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx b/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx index 6c37fd0d5a..6540aa3aa2 100644 --- a/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx +++ b/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx @@ -306,7 +306,7 @@ function nameToHex(name: string) { } export function hex2rgb(c: string): string { - if (c.startsWith('#')) { + if (c.indexOf('#') === 0) { let hexColor = c.replace(/^#/, '') // Handle 3-character shorthand (e.g., #111 -> #111111, #abc -> #aabbcc) if (/^[0-9A-Fa-f]{3}$/.test(hexColor)) { @@ -337,7 +337,7 @@ export function getContrastingTextColor(color: string = defaultSurveyAppearance. if (color[0] === '#') { rgb = hex2rgb(color) } - if (color.startsWith('rgb')) { + if (color.indexOf('rgb') === 0) { rgb = color } // otherwise it's a color name diff --git a/packages/browser/src/posthog-exceptions.ts b/packages/browser/src/posthog-exceptions.ts index 1ebea24be6..1eff33f7a8 100644 --- a/packages/browser/src/posthog-exceptions.ts +++ b/packages/browser/src/posthog-exceptions.ts @@ -236,7 +236,7 @@ export class PostHogExceptions implements Extension { private _isExtensionException(exceptionList: ErrorTracking.ExceptionList): boolean { const frames = exceptionList.flatMap((e) => e.stacktrace?.frames ?? []) - return frames.some((f) => f.filename && f.filename.startsWith('chrome-extension://')) + return frames.some((f) => f.filename && f.filename.indexOf('chrome-extension://') === 0) } private _isPostHogException(exceptionList: ErrorTracking.ExceptionList): boolean { diff --git a/packages/browser/src/posthog-surveys.ts b/packages/browser/src/posthog-surveys.ts index a4bed6d51e..a6e59dd39b 100644 --- a/packages/browser/src/posthog-surveys.ts +++ b/packages/browser/src/posthog-surveys.ts @@ -77,7 +77,7 @@ export class PostHogSurveys implements Extension { const surveyKeys = [] for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i) - if (key?.startsWith(SURVEY_SEEN_PREFIX) || key?.startsWith(SURVEY_IN_PROGRESS_PREFIX)) { + if (key && (key.indexOf(SURVEY_SEEN_PREFIX) === 0 || key.indexOf(SURVEY_IN_PROGRESS_PREFIX) === 0)) { surveyKeys.push(key) } } diff --git a/tooling/eslint-plugin-posthog-js/no-browser-startswith.js b/tooling/eslint-plugin-posthog-js/no-browser-startswith.js new file mode 100644 index 0000000000..5c5aea0f50 --- /dev/null +++ b/tooling/eslint-plugin-posthog-js/no-browser-startswith.js @@ -0,0 +1,43 @@ +function isBrowserSdkSourceFile(filename) { + const normalizedFilename = filename.replace(/\\/g, '/') + const isBrowserSrc = + normalizedFilename.includes('/packages/browser/src/') || + normalizedFilename.indexOf('packages/browser/src/') === 0 + return isBrowserSrc && !normalizedFilename.includes('/__tests__/') +} + +function isStartsWithCall(callee) { + if (!callee) { + return false + } + + if (callee.type === 'ChainExpression') { + return isStartsWithCall(callee.expression) + } + + if (callee.type !== 'MemberExpression') { + return false + } + + return callee.property?.type === 'Identifier' && callee.property.name === 'startsWith' +} + +module.exports = { + create(context) { + if (!isBrowserSdkSourceFile(context.getFilename())) { + return {} + } + + return { + CallExpression(node) { + if (isStartsWithCall(node.callee)) { + context.report({ + node, + message: + 'Do not use String.prototype.startsWith in the browser SDK — IE11 does not support it. Use indexOf(...) === 0 instead.', + }) + } + }, + } + }, +} diff --git a/tooling/eslint-plugin-posthog-js/no-browser-startswith.test.js b/tooling/eslint-plugin-posthog-js/no-browser-startswith.test.js new file mode 100644 index 0000000000..16b1df2c88 --- /dev/null +++ b/tooling/eslint-plugin-posthog-js/no-browser-startswith.test.js @@ -0,0 +1,48 @@ +const noBrowserStartsWith = require('./no-browser-startswith') +const { RuleTester } = require('eslint') + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + env: { + browser: true, + }, +}) + +const errorMessage = + 'Do not use String.prototype.startsWith in the browser SDK — IE11 does not support it. Use indexOf(...) === 0 instead.' + +ruleTester.run('no-browser-startswith', noBrowserStartsWith, { + valid: [ + { + code: "value.indexOf('prefix') === 0", + filename: '/project/packages/browser/src/something.ts', + }, + { + code: "value.indexOf('prefix') === 0", + filename: 'packages/browser/src/something.ts', + }, + { + code: "value.startsWith('prefix')", + filename: '/project/packages/browser/src/__tests__/something.test.ts', + }, + { + code: "value.startsWith('prefix')", + filename: '/project/packages/node/src/something.ts', + }, + ], + invalid: [ + { + code: "value.startsWith('prefix')", + filename: '/project/packages/browser/src/something.ts', + errors: [{ message: errorMessage }], + }, + { + code: "value?.startsWith('prefix')", + filename: '/project/packages/browser/src/something.ts', + errors: [{ message: errorMessage }], + }, + ], +}) From 505f21f6dec0b428a7b3373dee5f62eb17dd7d2e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 17:03:48 +0200 Subject: [PATCH 15/17] test: update playwright mocks for batch ingestion --- packages/browser/playwright/fixtures/network.ts | 2 +- packages/browser/playwright/mocked/capture.spec.ts | 12 +++++++----- .../browser/playwright/mocked/retry-queue.spec.ts | 6 +++--- .../session-recording-network-recorder.spec.ts | 5 +---- .../browser/playwright/mocked/slim-bundle.spec.ts | 2 +- .../mocked/utils/posthog-playwright-test-base.ts | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/browser/playwright/fixtures/network.ts b/packages/browser/playwright/fixtures/network.ts index b6fb2d762f..223792e432 100644 --- a/packages/browser/playwright/fixtures/network.ts +++ b/packages/browser/playwright/fixtures/network.ts @@ -113,7 +113,7 @@ export class NetworkPage { } async mockIngestion() { - await this.page.route('**/e/**', async (route) => { + await this.page.route(/\/(?:e|batch)\//, async (route) => { await route.fulfill({ headers: { loaded: 'mock captured' }, json: {}, diff --git a/packages/browser/playwright/mocked/capture.spec.ts b/packages/browser/playwright/mocked/capture.spec.ts index b7f718b1ce..98098efd6a 100644 --- a/packages/browser/playwright/mocked/capture.spec.ts +++ b/packages/browser/playwright/mocked/capture.spec.ts @@ -43,7 +43,7 @@ test.describe('event capture', () => { const captureRequests: Request[] = [] page.on('request', (request) => { - if (request.url().includes('/e/') && request.method() === 'POST') { + if (request.url().includes('/batch/') && request.method() === 'POST') { captureRequests.push(request) } }) @@ -55,14 +55,16 @@ test.describe('event capture', () => { await pollUntilCondition(page, () => captureRequests.length > 0) expect(captureRequests.length).toEqual(1) const captureRequest = captureRequests[0] - expect(captureRequest.headers()['content-type']).toEqual('text/plain') - expect(captureRequest.url()).toMatch(/gzip/) + expect(captureRequest.headers()['content-type']).toEqual('application/json') + expect(captureRequest.headers()['content-encoding']).toEqual('gzip') + expect(captureRequest.url()).not.toContain('compression') // webkit doesn't allow us to read the body for some reason // see e.g. https://github.com/microsoft/playwright/issues/6479 if (browserName !== 'webkit') { const payload = getGzipEncodedPayloady(captureRequest) - expect(payload.event).toEqual('$pageview') - expect(Object.keys(payload.properties).length).toBeGreaterThan(0) + expect(payload.api_key).toEqual('test token') + expect(payload.batch[0].event).toEqual('$pageview') + expect(Object.keys(payload.batch[0].properties).length).toBeGreaterThan(0) } }) diff --git a/packages/browser/playwright/mocked/retry-queue.spec.ts b/packages/browser/playwright/mocked/retry-queue.spec.ts index 4ed95affd2..9d7a91181b 100644 --- a/packages/browser/playwright/mocked/retry-queue.spec.ts +++ b/packages/browser/playwright/mocked/retry-queue.spec.ts @@ -16,7 +16,7 @@ test.describe('retry queue', () => { let successSeen = false // Mock the capture endpoint to fail initially, then succeed - await context.route('**/e/**', async (route) => { + await context.route(/\/batch\//, async (route) => { const request = route.request() captureRequests.push(request) @@ -85,7 +85,7 @@ test.describe('retry queue', () => { const captureRequests: Request[] = [] // Mock the capture endpoint to always fail - await context.route('**/e/**', async (route) => { + await context.route(/\/batch\//, async (route) => { captureRequests.push(route.request()) await route.fulfill({ status: 500, @@ -131,7 +131,7 @@ test.describe('retry queue', () => { // Mock the capture endpoint to fail initially let shouldSucceed = false - await context.route('**/e/**', async (route) => { + await context.route(/\/batch\//, async (route) => { captureRequests.push(route.request()) if (shouldSucceed) { await route.fulfill({ diff --git a/packages/browser/playwright/mocked/session-recording/session-recording-network-recorder.spec.ts b/packages/browser/playwright/mocked/session-recording/session-recording-network-recorder.spec.ts index 8971e5abd2..c1832e6b1a 100644 --- a/packages/browser/playwright/mocked/session-recording/session-recording-network-recorder.spec.ts +++ b/packages/browser/playwright/mocked/session-recording/session-recording-network-recorder.spec.ts @@ -149,10 +149,7 @@ test.beforeEach(async ({ context }) => { expect(hasEntry(/http:\/\/localhost:\d+\/playground\/cypress\//, 'navigation')).toBe(true) expect(hasEntry(/https:\/\/localhost:\d+\/static\/array.js/, 'script')).toBe(true) expect( - hasEntry( - /https:\/\/localhost:\d+\/array\/test%20token\/config\?ip=0&_=\d+&ver=1\.\d\d\d\.\d+/, - 'fetch' - ) + hasEntry(/https:\/\/localhost:\d+\/array\/test%20token\/config\?_=\d+&ver=1\.\d\d\d\.\d+/, 'fetch') ).toBe(true) expect( hasEntry(/https:\/\/localhost:\d+\/static\/(lazy-)?recorder.js\?v=1\.\d\d\d\.\d+/, 'script') diff --git a/packages/browser/playwright/mocked/slim-bundle.spec.ts b/packages/browser/playwright/mocked/slim-bundle.spec.ts index 0ea0eede64..1d17424694 100644 --- a/packages/browser/playwright/mocked/slim-bundle.spec.ts +++ b/packages/browser/playwright/mocked/slim-bundle.spec.ts @@ -89,7 +89,7 @@ test.describe('slim bundle + extension bundles (#3313)', () => { }) // Mock the capture endpoint - void context.route('**/e/*', (route) => { + void context.route(/\/(?:e|batch)\//, (route) => { route.fulfill({ status: 200, contentType: 'application/json', diff --git a/packages/browser/playwright/mocked/utils/posthog-playwright-test-base.ts b/packages/browser/playwright/mocked/utils/posthog-playwright-test-base.ts index fd8a9cb524..0e97435891 100644 --- a/packages/browser/playwright/mocked/utils/posthog-playwright-test-base.ts +++ b/packages/browser/playwright/mocked/utils/posthog-playwright-test-base.ts @@ -102,7 +102,7 @@ export const test = base.extend<{ }, mockStaticAssets: [ async ({ context, staticOverrides }, use) => { - void context.route('**/e/*', (route) => { + void context.route(/\/(?:e|batch)\//, (route) => { route.fulfill({ status: 200, contentType: 'application/json', From 51d74dc7a16ac05151d4a3e49510e4ce9dbc0fe5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 17:09:04 +0200 Subject: [PATCH 16/17] test: log batch ingestion in testcafe --- packages/browser/testcafe/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/testcafe/helpers.js b/packages/browser/testcafe/helpers.js index 0ed8e3df4d..d05d42e099 100644 --- a/packages/browser/testcafe/helpers.js +++ b/packages/browser/testcafe/helpers.js @@ -19,7 +19,7 @@ export const { const HEADERS = { Authorization: `Bearer ${POSTHOG_PERSONAL_API_KEY}` } -export const captureLogger = RequestLogger(/ip=0/, { +export const captureLogger = RequestLogger(/\/(?:e|batch)\//, { logRequestHeaders: true, logRequestBody: true, logResponseHeaders: true, From b6951a8b3e96477a3059b4bb2c930bb5dac5f4ca Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 19 May 2026 21:10:55 +0200 Subject: [PATCH 17/17] fix: remove unused retry count query param --- compliance/browser/adapter.js | 11 +---- .../playwright/mocked/retry-queue.spec.ts | 46 +++---------------- .../src/__tests__/posthog-core-also.test.ts | 26 ----------- .../browser/src/__tests__/retry-queue.test.ts | 20 +------- packages/browser/src/retry-queue.ts | 7 +-- 5 files changed, 10 insertions(+), 100 deletions(-) diff --git a/compliance/browser/adapter.js b/compliance/browser/adapter.js index 8408d2df2d..46cd109f69 100644 --- a/compliance/browser/adapter.js +++ b/compliance/browser/adapter.js @@ -79,13 +79,10 @@ global.XMLHttpRequest = function() { } } - const urlObj = new URL(requestUrl, 'http://dummy') - const retryCount = parseInt(urlObj.searchParams.get('retry_count') || '0', 10) - state.requestsMade.push({ timestamp_ms: Date.now(), status_code: xhr.status, - retry_attempt: retryCount, + retry_attempt: 0, event_count: events.length, uuid_list: events.map(e => e.uuid).filter(Boolean), }) @@ -153,14 +150,10 @@ global.fetch = async (url, options) => { // Note: Blob bodies (gzipped data) are not parsed } - // Extract retry count from URL if present - const urlObj = new URL(url) - const retryCount = parseInt(urlObj.searchParams.get('retry_count') || '0', 10) - state.requestsMade.push({ timestamp_ms: Date.now(), status_code: response.status, - retry_attempt: retryCount, + retry_attempt: 0, event_count: events.length, uuid_list: events.map(e => e.uuid).filter(Boolean), }) diff --git a/packages/browser/playwright/mocked/retry-queue.spec.ts b/packages/browser/playwright/mocked/retry-queue.spec.ts index 9d7a91181b..8adf37e047 100644 --- a/packages/browser/playwright/mocked/retry-queue.spec.ts +++ b/packages/browser/playwright/mocked/retry-queue.spec.ts @@ -50,28 +50,8 @@ test.describe('retry queue', () => { expect(successSeen).toBe(true) }).toPass({ timeout: 50000 }) - // Check that we got multiple requests - expect(captureRequests.length).toBeGreaterThanOrEqual(3) - - // Verify the first request had no retry_count - const firstRequest = captureRequests[0] - expect(firstRequest.url()).not.toContain('retry_count') - - // Verify retry_count increments - const retryCountMatches = captureRequests - .map((req) => { - const match = req.url().match(/retry_count=(\d+)/) - return match ? parseInt(match[1]) : null - }) - .filter((count) => count !== null) - - // Should see incrementing retry counts - expect(retryCountMatches.length).toBeGreaterThanOrEqual(2) - expect(retryCountMatches).toContain(1) - expect(retryCountMatches).toContain(2) - // Verify counts are actually incrementing (not stuck at 1) - const uniqueCounts = Array.from(new Set(retryCountMatches)) - expect(uniqueCounts.length).toBeGreaterThan(1) + // Check that we retried the failed requests before succeeding + expect(captureRequests.length).toBeGreaterThanOrEqual(maxErrorResponses + 1) // After success, record the count and verify no more requests arrive const requestCountAfterSuccess = captureRequests.length @@ -80,7 +60,7 @@ test.describe('retry queue', () => { }).toPass({ timeout: 5000 }) }) - test('stops retrying after 10 attempts', async ({ page, context }) => { + test('retries failed capture requests without unbounded attempts', async ({ page, context }) => { test.setTimeout(60000) const captureRequests: Request[] = [] @@ -107,23 +87,9 @@ test.describe('retry queue', () => { // We'll wait long enough to see at least 3-4 retries await page.waitForTimeout(25000) - // Extract all retry counts - const retryCountMatches = captureRequests - .map((req) => { - const match = req.url().match(/retry_count=(\d+)/) - return match ? parseInt(match[1]) : null - }) - .filter((count) => count !== null) - .sort((a, b) => a! - b!) - - // Should have some retries but not exceed 10 - expect(retryCountMatches.length).toBeGreaterThan(0) - const maxRetryCount = Math.max(...(retryCountMatches as number[])) - expect(maxRetryCount).toBeLessThanOrEqual(10) - - // Verify counts are incrementing - expect(retryCountMatches).toContain(1) - expect(retryCountMatches).toContain(2) + // Should have some retries but not exceed the initial attempt + 10 retries + expect(captureRequests.length).toBeGreaterThan(1) + expect(captureRequests.length).toBeLessThanOrEqual(11) }) test('immediately retries when coming back online', async ({ page, context }) => { diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index fd2535397e..69f69628ba 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -398,32 +398,6 @@ describe('posthog core', () => { requestSpy.mockRestore() }) - it('sends retried analytics requests with query params to /batch/', () => { - const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) - const posthog = posthogWith({ ...defaultConfig, request_batching: false }) - posthog._onRemoteConfig({ supportedCompression: [Compression.GZipJS] } as RemoteConfig) - requestSpy.mockClear() - - posthog._send_request({ - url: 'https://us.i.posthog.com/batch/?retry_count=1', - data: { event: 'event-name' }, - compression: Compression.GZipJS, - }) - - expect(requestSpy).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://us.i.posthog.com/batch/', - compression: Compression.GZipJS, - _useContentEncoding: true, - data: expect.objectContaining({ - api_key: 'testtoken', - batch: [expect.objectContaining({ event: 'event-name' })], - }), - }) - ) - requestSpy.mockRestore() - }) - it('sends sendBeacon analytics requests to /batch/', () => { const requestSpy = jest.spyOn(requestModule, 'request').mockImplementation(jest.fn()) const posthog = posthogWith({ ...defaultConfig, request_batching: false }) diff --git a/packages/browser/src/__tests__/retry-queue.test.ts b/packages/browser/src/__tests__/retry-queue.test.ts index 22efb1dabe..fef06cd5ca 100644 --- a/packages/browser/src/__tests__/retry-queue.test.ts +++ b/packages/browser/src/__tests__/retry-queue.test.ts @@ -100,25 +100,7 @@ describe('RetryQueue', () => { // clears queue expect(retryQueue.length).toEqual(0) expect(mockPosthog._send_request).toHaveBeenCalledTimes(4) - // Check the retry count is added - expect(mockPosthog._send_request.mock.calls.map(([arg1]) => arg1.url)).toEqual([ - '/e?retry_count=1', - '/e?retry_count=1', - '/e?retry_count=1', - '/e?retry_count=1', - ]) - }) - - it('adds the retry_count to the url', () => { - enqueueRequests() - fastForwardTimeAndRunTimer(3500) - - expect(mockPosthog._send_request.mock.calls.map(([arg1]) => arg1.url)).toEqual([ - '/e?retry_count=1', - '/e?retry_count=1', - '/e?retry_count=1', - '/e?retry_count=1', - ]) + expect(mockPosthog._send_request.mock.calls.map(([arg1]) => arg1.url)).toEqual(['/e', '/e', '/e', '/e']) }) it('tries to send requests via beacon on unload', () => { diff --git a/packages/browser/src/retry-queue.ts b/packages/browser/src/retry-queue.ts index 08ef5ac2ff..c873ebff25 100644 --- a/packages/browser/src/retry-queue.ts +++ b/packages/browser/src/retry-queue.ts @@ -1,10 +1,9 @@ import { RetriableRequestWithOptions } from './types' -import { isPositiveNumber, isUndefined } from '@posthog/core' +import { isUndefined } from '@posthog/core' import { logger } from './utils/logger' import { window } from './utils/globals' import { PostHog } from './posthog-core' -import { extendURLParams } from './request' import { addEventListener } from './utils' const thirtyMinutes = 30 * 60 * 1000 @@ -68,10 +67,6 @@ export class RetryQueue { } retriableRequest({ retriesPerformedSoFar, ...options }: RetriableRequestWithOptions): void { - if (isPositiveNumber(retriesPerformedSoFar)) { - options.url = extendURLParams(options.url, { retry_count: retriesPerformedSoFar }) - } - this._instance._send_request({ ...options, callback: (response) => {