@@ -52,6 +52,98 @@ interface CLIInput {
5252 config ?: CLIConfig
5353}
5454
55+ // --- Fetch Monitor ---
56+ // The browser SDK's Segment.io plugin handles retries internally and swallows
57+ // all errors (never fires delivery_failure events). We monitor fetch calls to
58+ // detect when delivery activity has settled and to observe final HTTP statuses.
59+
60+ let lastApiResponseTime = 0
61+ let inflightApiRequests = 0
62+ let lastApiStatus = 0
63+ let firstApiErrorStatus = 0
64+ let apiHostPattern = ''
65+
66+ function installFetchMonitor ( apiHost : string ) : void {
67+ apiHostPattern = apiHost . replace ( / ^ h t t p s ? : \/ \/ / , '' )
68+ const nativeFetch = globalThis . fetch
69+
70+ ; ( globalThis as any ) . fetch = async function monitoredFetch (
71+ input : RequestInfo | URL ,
72+ init ?: RequestInit
73+ ) : Promise < Response > {
74+ const url =
75+ typeof input === 'string'
76+ ? input
77+ : input instanceof URL
78+ ? input . href
79+ : ( input as Request ) . url
80+
81+ // Only monitor API requests, not CDN settings/project requests
82+ const isApi =
83+ apiHostPattern &&
84+ url . includes ( apiHostPattern ) &&
85+ ! url . includes ( '/settings' ) &&
86+ ! url . includes ( '/projects' )
87+
88+ if ( ! isApi ) {
89+ return nativeFetch . call ( globalThis , input , init )
90+ }
91+
92+ inflightApiRequests ++
93+ try {
94+ const response = await nativeFetch . call ( globalThis , input , init )
95+ lastApiStatus = response . status
96+ lastApiResponseTime = Date . now ( )
97+ if ( response . status >= 400 && firstApiErrorStatus === 0 ) {
98+ firstApiErrorStatus = response . status
99+ }
100+ return response
101+ } catch ( err ) {
102+ lastApiResponseTime = Date . now ( )
103+ throw err
104+ } finally {
105+ inflightApiRequests--
106+ }
107+ }
108+ }
109+
110+ /**
111+ * Wait for all API delivery activity to settle.
112+ *
113+ * The browser SDK's scheduleFlush uses a small random delay (100-600ms)
114+ * between retry cycles, plus exponential backoff from pushWithBackoff.
115+ * We wait until no API activity for a settling period.
116+ */
117+ async function waitForDelivery ( maxWaitMs = 60000 ) : Promise < void > {
118+ const start = Date . now ( )
119+
120+ // Wait for at least one API request
121+ while ( lastApiResponseTime === 0 && Date . now ( ) - start < maxWaitMs ) {
122+ await sleep ( 100 )
123+ }
124+
125+ // Wait until no in-flight requests and enough quiet time
126+ while ( Date . now ( ) - start < maxWaitMs ) {
127+ if ( inflightApiRequests > 0 ) {
128+ await sleep ( 100 )
129+ continue
130+ }
131+
132+ const elapsed = Date . now ( ) - lastApiResponseTime
133+ // After success: brief settle for any remaining event dispatches.
134+ // After error: longer settle to allow for retry scheduling + backoff.
135+ // The fetch-dispatcher's core backoff uses minTimeout=500ms with
136+ // exponential growth: attempt 5 reaches ~500*2^4*random ≈ 8-16s.
137+ // Use 20s settle to accommodate higher retry attempts.
138+ const settleMs = lastApiStatus < 400 ? 1500 : 20000
139+
140+ if ( elapsed >= settleMs ) {
141+ return
142+ }
143+ await sleep ( 200 )
144+ }
145+ }
146+
55147// --- Helpers ---
56148
57149function parseArgs ( ) : string | null {
@@ -63,7 +155,7 @@ function parseArgs(): string | null {
63155 return args [ inputIndex + 1 ]
64156}
65157
66- function delay ( ms : number ) : Promise < void > {
158+ function sleep ( ms : number ) : Promise < void > {
67159 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) )
68160}
69161
@@ -80,6 +172,11 @@ async function main(): Promise<void> {
80172
81173 const input : CLIInput = JSON . parse ( inputJson )
82174
175+ // Install fetch monitor BEFORE importing the SDK
176+ if ( input . apiHost ) {
177+ installFetchMonitor ( input . apiHost )
178+ }
179+
83180 // Create jsdom environment with the browser SDK
84181 const html = `
85182 <!DOCTYPE html>
@@ -112,7 +209,6 @@ async function main(): Promise<void> {
112209 ; ( global as any ) . XMLHttpRequest = window . XMLHttpRequest
113210
114211 // Import the browser SDK after setting up globals
115- // We need to dynamically import to ensure globals are set first
116212 const { AnalyticsBrowser } = await import ( '@segment/analytics-next' )
117213
118214 // Check if batching mode is enabled via environment variable
@@ -126,27 +222,40 @@ async function main(): Promise<void> {
126222 segmentConfig . protocol = protocol
127223
128224 if ( useBatching ) {
129- // Batching mode: pass full URL (with scheme) since we patched batched-dispatcher
130- // to check for existing scheme
131225 segmentConfig . apiHost = input . apiHost
132226 } else {
133- // Standard mode: fetch-dispatcher uses the URL directly
134227 const apiHostStripped = input . apiHost . replace ( / ^ h t t p s ? : \/ \/ / , '' )
135228 segmentConfig . apiHost = apiHostStripped + '/v1'
136229 }
137230 }
138231
232+ // Wire maxRetries and backoff timing through httpConfig — this controls
233+ // both the plugin's PriorityQueue (fetch-dispatcher path) and the
234+ // batched-dispatcher's internal retry loop.
235+ {
236+ const backoffConfig : Record < string , unknown > = {
237+ // Use a short base interval so batched-dispatcher backoff aligns with
238+ // fetch-dispatcher's core backoff (100ms base). The default 500ms base
239+ // produces gaps that exceed the CLI's settle-time detection.
240+ baseBackoffInterval : 0.1 ,
241+ }
242+ if ( input . config ?. maxRetries != null ) {
243+ backoffConfig . maxRetryCount = input . config . maxRetries
244+ }
245+ segmentConfig . httpConfig = { backoffConfig }
246+ }
247+
139248 if ( useBatching ) {
140249 segmentConfig . deliveryStrategy = {
141250 strategy : 'batching' ,
142251 config : {
143- size : input . config ?. flushAt ?? 1 , // flush immediately for testing
252+ size : input . config ?. flushAt ?? 1 ,
144253 timeout : 1000 ,
145254 } ,
146255 }
147256 }
148257
149- // Initialize analytics with the provided config
258+ // Initialize analytics
150259 const [ analytics ] = await AnalyticsBrowser . load (
151260 {
152261 writeKey : input . writeKey ,
@@ -160,21 +269,45 @@ async function main(): Promise<void> {
160269 }
161270 )
162271
272+ // Listen for delivery errors (now emitted by the Segment.io plugin)
273+ const deliveryErrors : string [ ] = [ ]
274+ analytics . on ( 'error' , ( err ) => {
275+ const reason = ( err as any ) . reason
276+ const msg =
277+ reason instanceof Error
278+ ? reason . message
279+ : String ( reason ?? ( err as any ) . code )
280+ deliveryErrors . push ( msg )
281+ } )
282+
163283 // Process event sequences
164284 for ( const seq of input . sequences ) {
165285 if ( seq . delayMs > 0 ) {
166- await delay ( seq . delayMs )
286+ await sleep ( seq . delayMs )
167287 }
168288
169289 for ( const event of seq . events ) {
170290 await sendEvent ( analytics , event )
171291 }
172292 }
173293
174- // Wait for events to be sent (browser SDK auto-flushes)
175- await delay ( 3000 )
176-
177- output = { success : true , sentBatches : 1 }
294+ // Wait for all delivery activity to settle
295+ await waitForDelivery ( )
296+
297+ // Determine success/failure from delivery errors (emitted by the
298+ // Segment.io plugin) and observed fetch responses as fallback.
299+ if ( deliveryErrors . length > 0 ) {
300+ output = { success : false , error : deliveryErrors [ 0 ] , sentBatches : 0 }
301+ } else if ( lastApiStatus >= 400 ) {
302+ // Fetch monitor fallback: last response was an error
303+ output = {
304+ success : false ,
305+ error : `HTTP ${ firstApiErrorStatus || lastApiStatus } ` ,
306+ sentBatches : 0 ,
307+ }
308+ } else {
309+ output = { success : true , sentBatches : 1 }
310+ }
178311
179312 // Cleanup
180313 dom . window . close ( )
0 commit comments