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/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/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/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..8adf37e047 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) @@ -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,12 +60,12 @@ 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[] = [] // 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, @@ -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 }) => { @@ -131,7 +97,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', 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..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/?ip=0') + expect(request.url).toBe('http://localhost/batch/') expect(request.data).toMatchObject({ event: '$exception', properties: { 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/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index fabd46c7e6..69f69628ba 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, SESSION_RECORDING_REMOTE_CONFIG, 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' @@ -301,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/', }) ) }) @@ -326,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) @@ -351,6 +375,51 @@ 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/', + compression: Compression.GZipJS, + _useContentEncoding: true, + data: expect.objectContaining({ + api_key: 'testtoken', + sent_at: '2020-01-01T00:00:00.000Z', + 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 }) + 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/batch/', + compression: Compression.GZipJS, + data: expect.objectContaining({ + api_key: 'testtoken', + batch: [expect.objectContaining({ event: 'event-name' })], + }), + transport: 'sendBeacon', + }) + ) + requestSpy.mockRestore() + }) + it.each(['XHR', 'fetch', 'sendBeacon'] as const)( 'passes the %s transport override to the request', (transport) => { @@ -416,12 +485,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/__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.` diff --git a/packages/browser/src/__tests__/request.test.ts b/packages/browser/src/__tests__/request.test.ts index 2ce7f76823..4787e1f9d6 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, + _useContentEncoding: true, + 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({ @@ -578,6 +598,40 @@ 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('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({ 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/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-core.ts b/packages/browser/src/posthog-core.ts index e2b5bc7139..66a92e1005 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -39,7 +39,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' @@ -109,6 +109,7 @@ import { isEmptyObject, isObject, isBoolean, + currentISOTime, } from '@posthog/core' import { uuidv7 } from './uuidv7' import { ExternalIntegrations } from './extensions/external-integration' @@ -159,6 +160,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' @@ -448,7 +451,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 @@ -1011,7 +1014,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 } @@ -1026,15 +1031,29 @@ export class PostHog implements PostHogInterface { } options.transport = options.transport || this.config.api_transport - options.url = extendURLParams(options.url, { - // Whether to detect ip info or not - ip: this.config.ip ? 1 : 0, - }) + options.compression = + options.compression === COMPRESSION_BEST_AVAILABLE ? this.compression : options.compression + + const batchEndpoint = this.requestRouter.endpointFor('api', '/batch/') + const isBatchAnalyticsRequest = options.url === batchEndpoint || options.url.indexOf(`${batchEndpoint}?`) === 0 + if (isBatchAnalyticsRequest && options.data) { + options.url = batchEndpoint + options.data = { + api_key: this.config.token, + batch: isArray(options.data) ? options.data : [options.data], + 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 + } + options.headers = { ...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'] @@ -1363,7 +1382,7 @@ 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, } 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/packages/browser/src/request.ts b/packages/browser/src/request.ts index b5ccb178ab..d0549b7c70 100644 --- a/packages/browser/src/request.ts +++ b/packages/browser/src/request.ts @@ -26,6 +26,7 @@ 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' const SIXTY_FOUR_KILOBYTES = 64 * 1024 /* fetch will fail if we request keepalive with a body greater than 64kb @@ -53,6 +54,7 @@ const removeURLParam = (url: string, param: string): string => { type EncodedBody = { contentType: string + contentEncoding?: string body: string | BlobPart | ArrayBuffer estimatedSize: number } @@ -62,6 +64,10 @@ type EncodedRequest = { encodedBody?: EncodedBody } +const useHTTPContentEncoding = (options: RequestWithEncodedBody): boolean => { + return !!options._useContentEncoding +} + /** * Extends a URL with additional query parameters * @param url - The URL to extend @@ -115,8 +121,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 +191,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 +213,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 +248,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 +260,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 +340,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(), + _: options._skipTimestampQueryParam ? undefined : new Date().getTime().toString(), ver: Config.JS_SDK_VERSION, - compression, + compression: useHTTPContentEncoding(options) ? undefined : options.compression, }) } @@ -364,26 +381,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._useContentEncoding = 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 +416,14 @@ export const request = (_options: RequestWithOptions) => { .catch((error) => { if (isNativeAsyncGzipReadError(error)) { nativeAsyncGzipDisabled = true - transportMethod({ + const uncompressedOptions = { ...options, compression: undefined, - url: buildRequestURL(_options.url, undefined), + _useContentEncoding: undefined, + } + transportMethod({ + ...uncompressedOptions, + url: buildRequestURL(_options.url, uncompressedOptions), }) return } 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) => { diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 620e532b21..d46a67677b 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -192,6 +192,16 @@ export interface RequestWithOptions { disableTransport?: ('XHR' | 'fetch' | 'sendBeacon')[] disableXHRCredentials?: boolean compression?: Compression | 'best-available' + /** + * Internal: when true, gzip is signaled using the standard HTTP + * 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 @@ -304,9 +314,12 @@ 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: "/e" } + * @default { endpoint: "/batch" } */ analytics?: { endpoint?: string 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", 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, 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 }], + }, + ], +})