Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
790c355
First response status changes for browser batch mode
MichaelGHSeg Jan 16, 2026
7ad65bb
Improving tests for batch dispatcher
MichaelGHSeg Jan 23, 2026
e92f798
More fetch dispatcher tests
MichaelGHSeg Jan 23, 2026
c860e62
Prospective change for updating 429 for Oauth endpoint - can be scale…
MichaelGHSeg Jan 23, 2026
0944201
Fixing browser oversize on retry issue, node response changes plus tests
MichaelGHSeg Jan 26, 2026
b32bf2a
Fixing timeouts and batching behavior
MichaelGHSeg Jan 29, 2026
ebd7f53
Updates for OAuth Token, fixing LIBRARIES-2977 and using updated Retr…
MichaelGHSeg Jan 29, 2026
31e275d
Always send X-Retry-Count and Authorization headers
MichaelGHSeg Feb 11, 2026
53bc05d
Add Retry-After cap and fix 413 handling
MichaelGHSeg Feb 11, 2026
7952be8
Fix node publisher to always send X-Retry-Count header
MichaelGHSeg Feb 12, 2026
ab34a07
Standardize backoff timing: 100ms min, 60s max
MichaelGHSeg Feb 12, 2026
de855e9
Increase default maxRetries to 1000
MichaelGHSeg Feb 12, 2026
937b970
Fix test failures from maxRetries increase to 1000
MichaelGHSeg Feb 12, 2026
2272886
Fix batched-dispatcher to flush remaining events after batch splits
MichaelGHSeg Feb 13, 2026
39495a0
Fix batched-dispatcher concurrency and flush issues
MichaelGHSeg Feb 13, 2026
369f01b
Cap Retry-After retries, fix maxRetries default, fix tests
MichaelGHSeg Feb 13, 2026
f0f8dc6
Fix CI test failures from backoff and header changes
MichaelGHSeg Feb 18, 2026
8533626
Guard against negative Retry-After and clockSkew values, add safety c…
MichaelGHSeg Feb 18, 2026
a08e4cb
Remove unused Jest manual mock for analytics-page-tools
MichaelGHSeg Feb 20, 2026
a12de67
Add config-driven status code helpers and wire httpConfig through dis…
MichaelGHSeg Feb 23, 2026
76b26d7
Wire exponential backoff and duration caps into batched dispatcher
MichaelGHSeg Feb 23, 2026
d99dfd8
Implement unified HTTP response handling per SDD (node + browser)
MichaelGHSeg Feb 25, 2026
51a12e5
Refine HTTP response handling: sleep-and-retry for 429, doc fixes
MichaelGHSeg Feb 25, 2026
f8f44c0
Address PR review: SDD comment and test name fixes
MichaelGHSeg Feb 25, 2026
087a8a4
Readjusting token min refresh time
MichaelGHSeg Feb 25, 2026
7ff2791
Addressing PR comments
MichaelGHSeg Feb 25, 2026
0406258
Fix test failures: X-Retry-Count default and 511 without auth
MichaelGHSeg Feb 26, 2026
5a99827
Merge branch 'master' of ssh://github.com/segmentio/analytics-next in…
MichaelGHSeg Feb 26, 2026
ed23808
Wire error event listener in e2e-cli for failure reporting
MichaelGHSeg Feb 26, 2026
c514bc2
Wire error event listener in browser e2e-cli for failure reporting
MichaelGHSeg Feb 26, 2026
9f094b3
Fix browser e2e-cli: replace fixed delay with fetch-based activity mo…
MichaelGHSeg Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 123 additions & 12 deletions packages/browser/e2e-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,98 @@ interface CLIInput {
config?: CLIConfig
}

// --- Fetch Monitor ---
// The browser SDK's Segment.io plugin handles retries internally and swallows
// all errors (never fires delivery_failure events). We monitor fetch calls to
// detect when delivery activity has settled and to observe final HTTP statuses.

let lastApiResponseTime = 0
let inflightApiRequests = 0
let lastApiStatus = 0
let firstApiErrorStatus = 0
let apiHostPattern = ''

function installFetchMonitor(apiHost: string): void {
apiHostPattern = apiHost.replace(/^https?:\/\//, '')
const nativeFetch = globalThis.fetch

;(globalThis as any).fetch = async function monitoredFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: (input as Request).url

// Only monitor API requests, not CDN settings/project requests
const isApi =
apiHostPattern &&
url.includes(apiHostPattern) &&
!url.includes('/settings') &&
!url.includes('/projects')

if (!isApi) {
return nativeFetch.call(globalThis, input, init)
}

inflightApiRequests++
try {
const response = await nativeFetch.call(globalThis, input, init)
lastApiStatus = response.status
lastApiResponseTime = Date.now()
if (response.status >= 400 && firstApiErrorStatus === 0) {
firstApiErrorStatus = response.status
}
return response
} catch (err) {
lastApiResponseTime = Date.now()
throw err
} finally {
inflightApiRequests--
}
}
}

/**
* Wait for all API delivery activity to settle.
*
* The browser SDK's scheduleFlush uses Math.random() * 5000 between retry
* cycles, so we need ~6.5s of silence after an error to be confident retries
* are done. After a success we settle faster (1.5s) since no more retries
* are expected for that event.
*/
async function waitForDelivery(maxWaitMs = 60000): Promise<void> {
const start = Date.now()

// Wait for at least one API request
while (lastApiResponseTime === 0 && Date.now() - start < maxWaitMs) {
await sleep(100)
}

// Wait until no in-flight requests and enough quiet time
while (Date.now() - start < maxWaitMs) {
if (inflightApiRequests > 0) {
await sleep(100)
continue
}

const elapsed = Date.now() - lastApiResponseTime
// The browser SDK's scheduleFlush uses Math.random() * 5000 between
// retry cycles. After errors we need >5s of silence for retries.
// After success we use a shorter settle but long enough for other
// events' pending dispatches.
const settleMs = lastApiStatus < 400 ? 3000 : 6500

if (elapsed >= settleMs) {
return
}
await sleep(200)
}
}

// --- Helpers ---

function parseArgs(): string | null {
Expand All @@ -63,7 +155,7 @@ function parseArgs(): string | null {
return args[inputIndex + 1]
}

function delay(ms: number): Promise<void> {
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

Expand All @@ -80,6 +172,11 @@ async function main(): Promise<void> {

const input: CLIInput = JSON.parse(inputJson)

// Install fetch monitor BEFORE importing the SDK
if (input.apiHost) {
installFetchMonitor(input.apiHost)
}

// Create jsdom environment with the browser SDK
const html = `
<!DOCTYPE html>
Expand Down Expand Up @@ -112,7 +209,6 @@ async function main(): Promise<void> {
;(global as any).XMLHttpRequest = window.XMLHttpRequest

// Import the browser SDK after setting up globals
// We need to dynamically import to ensure globals are set first
const { AnalyticsBrowser } = await import('@segment/analytics-next')

// Check if batching mode is enabled via environment variable
Expand All @@ -126,11 +222,8 @@ async function main(): Promise<void> {
segmentConfig.protocol = protocol

if (useBatching) {
// Batching mode: pass full URL (with scheme) since we patched batched-dispatcher
// to check for existing scheme
segmentConfig.apiHost = input.apiHost
} else {
// Standard mode: fetch-dispatcher uses the URL directly
const apiHostStripped = input.apiHost.replace(/^https?:\/\//, '')
segmentConfig.apiHost = apiHostStripped + '/v1'
}
Expand All @@ -140,13 +233,13 @@ async function main(): Promise<void> {
segmentConfig.deliveryStrategy = {
strategy: 'batching',
config: {
size: input.config?.flushAt ?? 1, // flush immediately for testing
size: input.config?.flushAt ?? 1,
timeout: 1000,
},
}
}

// Initialize analytics with the provided config
// Initialize analytics
const [analytics] = await AnalyticsBrowser.load(
{
writeKey: input.writeKey,
Expand All @@ -163,18 +256,36 @@ async function main(): Promise<void> {
// Process event sequences
for (const seq of input.sequences) {
if (seq.delayMs > 0) {
await delay(seq.delayMs)
await sleep(seq.delayMs)
}

for (const event of seq.events) {
await sendEvent(analytics, event)
}
}

// Wait for events to be sent (browser SDK auto-flushes)
await delay(3000)

output = { success: true, sentBatches: 1 }
// Wait for all delivery activity to settle
await waitForDelivery()

// Determine success/failure from observed fetch responses.
// The Segment.io plugin swallows all errors internally, so we can't
// rely on analytics.on('error'). Instead we use the fetch monitor.
if (lastApiStatus < 400 && firstApiErrorStatus === 0) {
// All API responses were successful
output = { success: true, sentBatches: 1 }
} else if (lastApiStatus < 400) {
// Last response was success (retries worked), but there were errors.
// If the only errors were retryable ones that eventually succeeded,
// this is a success.
output = { success: true, sentBatches: 1 }
} else {
// Last response was an error — either non-retryable or retries exhausted
output = {
success: false,
error: `HTTP ${firstApiErrorStatus || lastApiStatus}`,
sentBatches: 0,
}
}

// Cleanup
dom.window.close()
Expand Down
3 changes: 3 additions & 0 deletions packages/browser/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ module.exports = createJestTSConfig(__dirname, {
modulePathIgnorePatterns: ['<rootDir>/e2e-tests', '<rootDir>/qa'],
setupFilesAfterEnv: ['./jest.setup.js'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@segment/analytics-page-tools$': '<rootDir>/../page-tools/src',
},
coverageThreshold: {
global: {
branches: 0,
Expand Down
10 changes: 6 additions & 4 deletions packages/browser/src/browser/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1603,9 +1603,11 @@ describe('setting headers', () => {
const [call] = fetchCalls.filter((el) =>
el.url.toString().includes('api.segment.io')
)
expect(call.headers).toEqual({
'Content-Type': 'text/plain',
'X-Test': 'foo',
})
expect(call.headers).toEqual(
expect.objectContaining({
'Content-Type': 'text/plain',
'X-Test': 'foo',
})
)
})
})
10 changes: 9 additions & 1 deletion packages/browser/src/browser/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { UserOptions } from '../core/user'
import { HighEntropyHint } from '../lib/client-hints/interfaces'
import { IntegrationsOptions } from '@segment/analytics-core'
import { SegmentioSettings } from '../plugins/segmentio'
import { HttpConfig } from '../plugins/segmentio/shared-dispatcher'

interface VersionSettings {
version?: string
Expand Down Expand Up @@ -74,6 +75,13 @@ export interface RemoteSegmentIOIntegrationSettings
bundledConfigIds?: string[]
unbundledConfigIds?: string[]
maybeBundledConfigIds?: Record<string, string[]>

/**
* HTTP retry and backoff configuration.
* Controls rate-limit handling (429) and exponential backoff for transient errors.
* Fetched from CDN settings; can be overridden via init options.
*/
httpConfig?: HttpConfig
}

/**
Expand Down Expand Up @@ -188,7 +196,7 @@ export interface AnalyticsSettings {
*/
export type SegmentioIntegrationInitOptions = Pick<
SegmentioSettings,
'apiHost' | 'protocol' | 'deliveryStrategy'
'apiHost' | 'protocol' | 'deliveryStrategy' | 'httpConfig'
>

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { backoff } from '../backoff'

describe('backoff', () => {
it('increases with the number of attempts', () => {
expect(backoff({ attempt: 1 })).toBeGreaterThan(1000)
expect(backoff({ attempt: 2 })).toBeGreaterThan(2000)
expect(backoff({ attempt: 3 })).toBeGreaterThan(3000)
expect(backoff({ attempt: 4 })).toBeGreaterThan(4000)
expect(backoff({ attempt: 1 })).toBeGreaterThan(200)
expect(backoff({ attempt: 2 })).toBeGreaterThan(400)
expect(backoff({ attempt: 3 })).toBeGreaterThan(800)
expect(backoff({ attempt: 4 })).toBeGreaterThan(1600)
})

it('accepts a max timeout', () => {
expect(backoff({ attempt: 1, maxTimeout: 3000 })).toBeGreaterThan(1000)
expect(backoff({ attempt: 1, maxTimeout: 3000 })).toBeGreaterThan(200)
expect(backoff({ attempt: 3, maxTimeout: 3000 })).toBeLessThanOrEqual(3000)
expect(backoff({ attempt: 4, maxTimeout: 3000 })).toBeLessThanOrEqual(3000)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('backoffs', () => {
expect(spy).toHaveBeenCalled()

const delay = spy.mock.calls[0][1]
expect(delay).toBeGreaterThan(1000)
expect(delay).toBeGreaterThan(200)
})

it('increases the delay as work gets requeued', () => {
Expand All @@ -147,12 +147,12 @@ describe('backoffs', () => {
queue.pop()

const firstDelay = spy.mock.calls[0][1]
expect(firstDelay).toBeGreaterThan(1000)
expect(firstDelay).toBeGreaterThan(200)

const secondDelay = spy.mock.calls[1][1]
expect(secondDelay).toBeGreaterThan(2000)
expect(secondDelay).toBeGreaterThan(400)

const thirdDelay = spy.mock.calls[2][1]
expect(thirdDelay).toBeGreaterThan(3000)
expect(thirdDelay).toBeGreaterThan(800)
})
})
11 changes: 3 additions & 8 deletions packages/browser/src/lib/priority-queue/backoff.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
type BackoffParams = {
/** The number of milliseconds before starting the first retry. Default is 500 */
/** The number of milliseconds before starting the first retry. Default is 100 */
minTimeout?: number

/** The maximum number of milliseconds between two retries. Default is Infinity */
/** The maximum number of milliseconds between two retries. Default is 60000 (1 minute) */
maxTimeout?: number

/** The exponential factor to use. Default is 2. */
Expand All @@ -14,11 +14,6 @@ type BackoffParams = {

export function backoff(params: BackoffParams): number {
const random = Math.random() + 1
const {
minTimeout = 500,
factor = 2,
attempt,
maxTimeout = Infinity,
} = params
const { minTimeout = 100, factor = 2, attempt, maxTimeout = 60000 } = params
return Math.min(random * minTimeout * Math.pow(factor, attempt), maxTimeout)
}
Loading
Loading