@devtune/ai-traffic 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * ABOUTME: Implements the DevTune AI Traffic edge-push client.
3
- * Handles registry refresh, AI bot matching, batched ingest beacons, and route wrappers.
3
+ * Handles registry refresh, AI bot and AI referral matching, batched ingest beacons, and route wrappers.
4
4
  */
5
- import { FALLBACK_AI_BOT_REGISTRY_ENTRIES, compileAiBotRegistryEntries, matchAiBotRegistryEntry, parseAiBotRegistryPayload, } from "./registry.js";
5
+ import { FALLBACK_AI_BOT_REGISTRY_ENTRIES, compileAiBotRegistryEntries, isRecord, matchAiBotRegistryEntry, parseAiBotRegistryPayload, } from "./registry.js";
6
+ import { FALLBACK_AI_REFERRAL_SOURCES, matchAiReferralHost, matchAiReferralUtmSource, parseAiReferralSources, } from "./referral.js";
6
7
  import { detectTrustedProxy, resolveClientIp, } from "./client-ip.js";
7
8
  import { SDK_VERSION, SDK_VERSION_HEADER } from "./version.js";
8
9
  import { hasWebBotAuthHeaders, readWebBotAuth, } from "./web-bot-auth.js";
@@ -20,6 +21,9 @@ const MAX_EVENT_PATH_LENGTH = 2048;
20
21
  const MAX_EVENT_UA_LENGTH = 2048;
21
22
  const MAX_EVENT_URL_LENGTH = 4096;
22
23
  const MAX_EVENT_IP_LENGTH = 45;
24
+ const MAX_EVENT_REFERRER_LENGTH = 2048;
25
+ const MAX_CAMPAIGN_VALUE_LENGTH = 512;
26
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
23
27
  const UNAUTHORIZED_STATUS = 401;
24
28
  const RATE_LIMITED_STATUS = 429;
25
29
  const UNAVAILABLE_STATUS = 503;
@@ -29,6 +33,7 @@ const RETRY_BASE_DELAY_MS = 250;
29
33
  const DEFAULT_MAX_RETRY_DELAY_MS = 5_000;
30
34
  const RETRY_DELAY_CEILING_MS = 60_000;
31
35
  const DEFAULT_MAX_QUEUED_EVENTS = 1_000;
36
+ const DEFAULT_MAX_QUEUED_REFERRAL_EVENTS = 200;
32
37
  const MAX_QUEUED_EVENTS_CEILING = 100_000;
33
38
  const AI_BOT_USER_AGENT_HINTS = [
34
39
  "-ai",
@@ -72,6 +77,8 @@ export class DevTuneAiTrafficClient {
72
77
  maxRetryAttempts;
73
78
  maxRetryDelayMs;
74
79
  maxQueuedEvents;
80
+ maxQueuedReferralEvents;
81
+ captureReferrals;
75
82
  trustedProxy;
76
83
  projectId;
77
84
  keyCredential;
@@ -83,6 +90,7 @@ export class DevTuneAiTrafficClient {
83
90
  flushPromise = null;
84
91
  delayedFlushPromise = null;
85
92
  queue = [];
93
+ referralQueue = [];
86
94
  constructor(options) {
87
95
  this.options = options;
88
96
  if (!options.ingestKey && !options.projectId) {
@@ -117,58 +125,57 @@ export class DevTuneAiTrafficClient {
117
125
  this.maxRetryAttempts = clampInteger(options.maxRetryAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS, 0, RETRY_ATTEMPTS_CEILING);
118
126
  this.maxRetryDelayMs = clampInteger(options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS, 0, RETRY_DELAY_CEILING_MS);
119
127
  this.maxQueuedEvents = clampInteger(options.maxQueuedEvents ?? DEFAULT_MAX_QUEUED_EVENTS, MAX_INGEST_BATCH_SIZE, MAX_QUEUED_EVENTS_CEILING);
128
+ this.maxQueuedReferralEvents = clampInteger(options.maxQueuedReferralEvents ?? DEFAULT_MAX_QUEUED_REFERRAL_EVENTS, MAX_INGEST_BATCH_SIZE, MAX_QUEUED_EVENTS_CEILING);
129
+ this.captureReferrals = options.captureReferrals ?? true;
120
130
  const fallbackEntries = compileAiBotRegistryEntries(FALLBACK_AI_BOT_REGISTRY_ENTRIES);
121
131
  const fallbackPrefilter = createRegistryPrefilter(fallbackEntries);
122
132
  this.registry = {
123
133
  entries: fallbackEntries,
124
134
  etag: null,
125
135
  fetchedAt: 0,
136
+ referrers: FALLBACK_AI_REFERRAL_SOURCES,
126
137
  ...fallbackPrefilter,
127
138
  version: "bundled",
128
139
  };
129
140
  }
141
+ /**
142
+ * A cheap check that `trackRequest()` could record this request: true for
143
+ * an AI bot candidate, by user agent alone, or for an AI referral candidate
144
+ * (see `matchReferralPlatform`). Adapters that record after the response
145
+ * call it first, so other requests cost no response work. A request without
146
+ * a `url` is checked as a bot candidate only.
147
+ */
130
148
  mightTrackRequest(request) {
131
149
  const userAgent = request.headers.get("user-agent");
132
150
  if (!userAgent) {
133
151
  return false;
134
152
  }
135
- if (mightBeAiBotUserAgent(userAgent) || this.registry.requiresFullPrefilterScan) {
136
- return true;
137
- }
138
- const normalized = userAgent.toLowerCase();
139
- return this.registry.userAgentHints.some((hint) => normalized.includes(hint));
153
+ return this.mightBeAiBot(userAgent) || this.matchReferralPlatform(request) !== null;
140
154
  }
141
155
  /**
142
- * Records a matched AI bot request. Pass `receivedAt` when the request is
143
- * recorded after its response, taken from `signedArrivalTime()` as the
144
- * request arrived: a signed event is checked against the time it was signed
145
- * for. Other events keep the time they are recorded.
156
+ * Records a matched AI bot request or AI referral visit. A user agent in
157
+ * the bot registry makes it a bot request whatever its `Referer`. Pass
158
+ * `receivedAt` when the request is recorded after its response, taken from
159
+ * `signedArrivalTime()` as the request arrived: a signed bot event is
160
+ * checked against the time it was signed for. Other events keep the time
161
+ * they are recorded.
146
162
  */
147
163
  trackRequest(request, waitUntilContext, responseStatus, receivedAt) {
148
164
  this.refreshRegistryIfStale(waitUntilContext);
149
165
  this.captureVercelOidcToken(request);
150
- if (!this.mightTrackRequest(request) ||
151
- isPageInitiatedRequest(request) ||
152
- isFrameworkDataRequest(request)) {
153
- return null;
154
- }
155
166
  const userAgent = request.headers.get("user-agent");
156
- if (!userAgent) {
167
+ const source = userAgent ? this.matchSource(request, userAgent) : null;
168
+ if (!userAgent || !source) {
157
169
  return null;
158
170
  }
159
- const entry = matchAiBotRegistryEntry(userAgent, this.registry.entries);
160
- if (!entry) {
161
- return null;
162
- }
163
- const event = this.createEvent(request, userAgent, responseStatus, receivedAt);
171
+ const event = source.kind === "bot"
172
+ ? this.createBotEvent(request, userAgent, responseStatus, receivedAt)
173
+ : this.createReferralEvent(request, userAgent, responseStatus);
164
174
  if (!event) {
165
175
  return null;
166
176
  }
167
- this.enqueue(event, waitUntilContext);
168
- return {
169
- entry,
170
- event,
171
- };
177
+ this.enqueue(source.kind, event, waitUntilContext);
178
+ return { ...source, event };
172
179
  }
173
180
  /**
174
181
  * The client's clock now for a request carrying Web Bot Auth signature
@@ -207,7 +214,8 @@ export class DevTuneAiTrafficClient {
207
214
  if (!response.ok) {
208
215
  throw new Error(`Registry request failed with status ${response.status}`);
209
216
  }
210
- const payload = parseAiBotRegistryPayload(await response.json());
217
+ const body = await response.json();
218
+ const payload = parseAiBotRegistryPayload(body);
211
219
  if (!payload) {
212
220
  throw new Error("Registry response was not a valid DevTune registry");
213
221
  }
@@ -219,15 +227,38 @@ export class DevTuneAiTrafficClient {
219
227
  entries,
220
228
  etag: response.headers.get("etag") ?? `"${payload.version}"`,
221
229
  fetchedAt: Date.now(),
230
+ // Missing or unusable referrers, as from a registry older than referral
231
+ // capture, keep the last working lists.
232
+ referrers: parseAiReferralSources(isRecord(body) ? body.referrers : undefined) ??
233
+ this.registry.referrers,
222
234
  ...createRegistryPrefilter(entries),
223
235
  version: payload.version,
224
236
  };
225
237
  }
226
238
  async flush() {
239
+ return this.startDrain(true);
240
+ }
241
+ /**
242
+ * Starts a drain, or joins the one in flight. A drain that spends a retry
243
+ * budget leaves events queued that would otherwise wait for the next tracked
244
+ * request, so it schedules one follow-up drain, no sooner than a capped
245
+ * retry's wait. The follow-up schedules none: if it is throttled too, the
246
+ * rest waits for the next tracked request, as before 0.13.0. The timer is
247
+ * unref'd and not tied to `waitUntil`, so only a long-lived process runs it.
248
+ */
249
+ startDrain(followUpWhenThrottled) {
227
250
  if (this.flushPromise) {
228
251
  return this.flushPromise;
229
252
  }
230
- this.flushPromise = this.flushQueuedEvents().finally(() => {
253
+ this.flushPromise = this.flushQueuedEvents()
254
+ .then((spentRetries) => {
255
+ if (spentRetries && followUpWhenThrottled && this.queuedEventCount() > 0) {
256
+ const timer = setTimeout(() => scheduleBackground(this.startDrain(false)), Math.max(this.flushIntervalMs, this.maxRetryDelayMs));
257
+ // Retrying telemetry must never hold a Node process open.
258
+ timer.unref?.();
259
+ }
260
+ })
261
+ .finally(() => {
231
262
  this.flushPromise = null;
232
263
  });
233
264
  return this.flushPromise;
@@ -253,26 +284,133 @@ export class DevTuneAiTrafficClient {
253
284
  });
254
285
  scheduleBackground(this.refreshPromise, this.waitUntilRegistryRefresh ? waitUntilContext : undefined);
255
286
  }
256
- createEvent(request, userAgent, responseStatus, receivedAt) {
257
- const url = parseRequestUrl(request.url);
258
- if (!url) {
287
+ mightBeAiBot(userAgent) {
288
+ if (mightBeAiBotUserAgent(userAgent) || this.registry.requiresFullPrefilterScan) {
289
+ return true;
290
+ }
291
+ const normalized = userAgent.toLowerCase();
292
+ return this.registry.userAgentHints.some((hint) => normalized.includes(hint));
293
+ }
294
+ /**
295
+ * A bot registry match that is not a page's own request, or failing that the
296
+ * AI platform a referral visit came from. A bot user agent wins: it is never
297
+ * recorded as a referral.
298
+ */
299
+ matchSource(request, userAgent) {
300
+ const entry = this.mightBeAiBot(userAgent)
301
+ ? matchAiBotRegistryEntry(userAgent, this.registry.entries)
302
+ : null;
303
+ if (entry) {
304
+ return isPageInitiatedRequest(request) || isFrameworkDataRequest(request)
305
+ ? null
306
+ : { kind: "bot", entry };
307
+ }
308
+ const platform = this.matchReferralPlatform(request);
309
+ return platform ? { kind: "referral", platform } : null;
310
+ }
311
+ /**
312
+ * The AI platform a referral candidate came from, or null. A candidate is a
313
+ * `GET` from another site (`Sec-Fetch-Site` not `same-origin` or
314
+ * `same-site`) whose `Referer` host, or failing that `utm_source`, is in the
315
+ * registry, and that loads a top-level page: `Sec-Fetch-Dest` is `document`
316
+ * or absent, it is not a prefetch, and it is not a framework data request.
317
+ * Frames stay bot-only. Every request reaches this, so the method and
318
+ * fetch-site checks and substring tests run before any URL is parsed.
319
+ * Browsers send the `Referer` host lower-cased, as the registry lists it.
320
+ * Always null when `captureReferrals` is off.
321
+ */
322
+ matchReferralPlatform(request) {
323
+ const { headers, method, url } = request;
324
+ if (!this.captureReferrals ||
325
+ (method !== "GET" && method !== "get") ||
326
+ typeof url !== "string") {
327
+ return null;
328
+ }
329
+ const site = headers.get("sec-fetch-site");
330
+ if (site === "same-origin" || site === "same-site") {
331
+ return null;
332
+ }
333
+ const platform = this.matchReferrerOrUtmSource(headers.get("referer"), url);
334
+ const destination = headers.get("sec-fetch-dest");
335
+ if (!platform ||
336
+ (destination !== null && destination !== "document") ||
337
+ isPageInitiatedRequest(request) ||
338
+ isFrameworkDataRequest({ headers, url })) {
339
+ return null;
340
+ }
341
+ return platform;
342
+ }
343
+ /** The `Referer` host's platform, or else the `utm_source`'s, as DevTune's classifier orders them. */
344
+ matchReferrerOrUtmSource(referer, url) {
345
+ const { referrers } = this.registry;
346
+ if (referer && mentionsAnyHost(referer, referrers.hosts)) {
347
+ const referrer = parseHttpUrl(referer);
348
+ const platform = referrer ? matchAiReferralHost(referrer.hostname, referrers.hosts) : null;
349
+ if (platform) {
350
+ return platform;
351
+ }
352
+ }
353
+ if (!url.includes("utm_source=")) {
354
+ return null;
355
+ }
356
+ const source = parseRequestUrl(url)?.searchParams.get("utm_source");
357
+ return source ? matchAiReferralUtmSource(source, referrers) : null;
358
+ }
359
+ createBotEvent(request, userAgent, responseStatus, receivedAt) {
360
+ const page = this.createBaseFields(request, userAgent, responseStatus);
361
+ if (!page) {
259
362
  return null;
260
363
  }
261
- const pathname = truncateString(url.pathname || "/", MAX_EVENT_PATH_LENGTH);
262
- const status = this.resolveStatus(request, pathname, responseStatus);
263
364
  const ip = resolveClientIp(request, this.trustedProxy);
264
365
  const webBotAuth = readWebBotAuthSafely(request);
265
366
  const ts = webBotAuth && receivedAt && Number.isFinite(receivedAt.getTime()) ? receivedAt : this.now();
266
367
  return {
267
- path: pathname,
268
- url: truncateString(`${url.origin}${pathname}`, MAX_EVENT_URL_LENGTH),
269
- ua: truncateString(userAgent, MAX_EVENT_UA_LENGTH),
270
- ...(status === null ? {} : { status }),
368
+ ...page.fields,
271
369
  ts: ts.toISOString(),
272
370
  ...(ip === null ? {} : { ip: truncateString(ip, MAX_EVENT_IP_LENGTH) }),
273
371
  ...(webBotAuth === null ? {} : { webBotAuth }),
274
372
  };
275
373
  }
374
+ /**
375
+ * A referral visit carries its `Referer` origin and campaign parameters,
376
+ * and never an address or signature material: there is no claimed crawler
377
+ * identity to verify. A visit answered with a redirect is not recorded:
378
+ * the browser takes the same `Referer` to the redirect's target, which is
379
+ * where DevTune counts it, so ingest drops the redirect's event.
380
+ */
381
+ createReferralEvent(request, userAgent, responseStatus) {
382
+ const page = this.createBaseFields(request, userAgent, responseStatus);
383
+ if (!page || (page.fields.status !== undefined && REDIRECT_STATUSES.has(page.fields.status))) {
384
+ return null;
385
+ }
386
+ const referrer = readReferrerOrigin(request.headers.get("referer"));
387
+ const campaign = readCampaign(page.url);
388
+ return {
389
+ ...page.fields,
390
+ ts: this.now().toISOString(),
391
+ ...(referrer === null ? {} : { referrer }),
392
+ ...(campaign === null ? {} : { campaign }),
393
+ };
394
+ }
395
+ /** The fields every event carries. The `url` is the origin and path, without the query. */
396
+ createBaseFields(request, userAgent, responseStatus) {
397
+ const url = parseRequestUrl(request.url);
398
+ if (!url) {
399
+ return null;
400
+ }
401
+ const pathname = truncateString(url.pathname || "/", MAX_EVENT_PATH_LENGTH);
402
+ const status = this.resolveStatus(request, pathname, responseStatus);
403
+ return {
404
+ url,
405
+ fields: {
406
+ path: pathname,
407
+ url: truncateString(`${url.origin}${pathname}`, MAX_EVENT_URL_LENGTH),
408
+ ua: truncateString(userAgent, MAX_EVENT_UA_LENGTH),
409
+ ...(status === null ? {} : { status }),
410
+ ...(isBehindCdn(request.headers) ? { cdn: true } : {}),
411
+ },
412
+ };
413
+ }
276
414
  resolveStatus(request, pathname, responseStatus) {
277
415
  if (responseStatus === null) {
278
416
  return null;
@@ -310,24 +448,20 @@ export class DevTuneAiTrafficClient {
310
448
  return false;
311
449
  }
312
450
  /**
313
- * Adds an event to the queue and decides when to send.
451
+ * Adds an event to its queue and decides when to send.
314
452
  *
315
453
  * Backing off on a 429 means the queue can now drain slower than traffic
316
454
  * arrives, which it never could when every batch cost exactly one request.
317
455
  * The bound is what keeps that from growing without limit in a customer's
318
456
  * process: past `maxQueuedEvents` the oldest events are shed, because the
319
- * newest ones are the ones still worth measuring.
457
+ * newest ones are the ones still worth measuring. Referral visits have
458
+ * their own queue and bound, `maxQueuedReferralEvents`, so a burst of them
459
+ * never sheds a bot event.
320
460
  */
321
- enqueue(event, waitUntilContext) {
322
- this.queue.push(event);
323
- if (this.queue.length > this.maxQueuedEvents) {
324
- const shed = this.queue.splice(0, this.queue.length - this.maxQueuedEvents);
325
- this.sampleWarn("DevTune AI Traffic ingest queue overflowed", {
326
- droppedEvents: shed.length,
327
- maxQueuedEvents: this.maxQueuedEvents,
328
- });
329
- }
330
- if (this.queue.length >= this.batchSize) {
461
+ enqueue(kind, event, waitUntilContext) {
462
+ (kind === "bot" ? this.queue : this.referralQueue).push(event);
463
+ this.shedOverflow(kind);
464
+ if (this.queuedEventCount() >= this.batchSize) {
331
465
  scheduleBackground(this.flush(), waitUntilContext);
332
466
  return;
333
467
  }
@@ -342,8 +476,37 @@ export class DevTuneAiTrafficClient {
342
476
  });
343
477
  scheduleBackground(this.delayedFlushPromise, waitUntilContext);
344
478
  }
479
+ /** Sheds the oldest events past a queue's bound, with a sampled warning. */
480
+ shedOverflow(kind) {
481
+ const bot = kind === "bot";
482
+ const queue = bot ? this.queue : this.referralQueue;
483
+ const maxEvents = bot ? this.maxQueuedEvents : this.maxQueuedReferralEvents;
484
+ if (queue.length <= maxEvents) {
485
+ return;
486
+ }
487
+ const droppedEvents = queue.splice(0, queue.length - maxEvents).length;
488
+ if (bot) {
489
+ this.sampleWarn("DevTune AI Traffic ingest queue overflowed", {
490
+ droppedEvents,
491
+ maxQueuedEvents: maxEvents,
492
+ });
493
+ }
494
+ else {
495
+ this.sampleWarn("DevTune AI Traffic referral queue overflowed", {
496
+ droppedEvents,
497
+ maxQueuedReferralEvents: maxEvents,
498
+ });
499
+ }
500
+ }
345
501
  /**
346
- * Drains the queue one wire batch at a time.
502
+ * Drains the queues one wire batch at a time, bots before referrals. A
503
+ * batch never mixes the two, and referrals never cost bots: each has its own
504
+ * retry budget per flush, a referral batch that spends its budget is dropped
505
+ * while bots keep draining, and a referral batch that gets a 429 while bots
506
+ * are waiting goes back to the front of its queue after the backoff, with
507
+ * its attempt count, so the bots are sent first. When the bots' budget is
508
+ * spent the drain ends there: the rate limit covers both kinds, so referrals
509
+ * would only meet the same 429, and the next drain starts with bots.
347
510
  *
348
511
  * A 429 is the one failure the ingest endpoint tells us is temporary, so the
349
512
  * batch is held out of the queue and redelivered after the server's
@@ -353,14 +516,14 @@ export class DevTuneAiTrafficClient {
353
516
  * are not folded into the batch being retried, so they cannot be dropped on a
354
517
  * bound they never had a chance to spend.
355
518
  *
356
- * The retry budget belongs to the whole drain, not to each batch. A per-batch
519
+ * Each budget belongs to the whole drain, not to each batch. A per-batch
357
520
  * bound says nothing about how long a drain can run, because a throttled
358
521
  * endpoint rejects every batch in turn and traffic keeps arriving behind it.
359
- * One budget per pass caps the added latency of a flush at
360
- * `maxRetryAttempts * maxRetryDelayMs` no matter how deep the queue is. When
361
- * it is spent the current batch is dropped with the usual sampled warning and
362
- * the drain stops, leaving the rest queued for the next flush, which gets a
363
- * fresh budget.
522
+ * One budget per kind per pass caps the added latency of a flush at
523
+ * `2 * maxRetryAttempts * maxRetryDelayMs` no matter how deep the queues
524
+ * are. When a budget is spent the current batch is dropped with the usual
525
+ * sampled warning, leaving the rest queued for the next flush, which gets
526
+ * fresh budgets. Resolves true when a budget was spent.
364
527
  *
365
528
  * Every other failure keeps its previous behavior: one attempt, dropped, and
366
529
  * a sampled warning.
@@ -375,12 +538,26 @@ export class DevTuneAiTrafficClient {
375
538
  * keys, is retried exactly like a 429.
376
539
  */
377
540
  async flushQueuedEvents() {
378
- let retryBudget = this.maxRetryAttempts;
541
+ const retryBudgets = { bot: this.maxRetryAttempts, referral: this.maxRetryAttempts };
542
+ let referralsStopped = false;
379
543
  let keyOnly = false;
380
544
  const failedExchanges = new Map();
381
- while (this.queue.length > 0) {
382
- const batch = this.queue.splice(0, MAX_INGEST_BATCH_SIZE);
383
- let attempts = 0;
545
+ // The last referral batch that stepped aside for bots. The queue bound
546
+ // sheds from the front, so its last event is the one that survives.
547
+ let requeued = null;
548
+ for (;;) {
549
+ const kind = this.queue.length > 0
550
+ ? "bot"
551
+ : !referralsStopped && this.referralQueue.length > 0
552
+ ? "referral"
553
+ : null;
554
+ if (kind === null) {
555
+ return referralsStopped;
556
+ }
557
+ const batch = (kind === "bot" ? this.queue : this.referralQueue).splice(0, MAX_INGEST_BATCH_SIZE);
558
+ let attempts = kind === "referral" && requeued?.last !== undefined && batch.includes(requeued.last)
559
+ ? requeued.attempts
560
+ : 0;
384
561
  for (;;) {
385
562
  // The key path stays synchronous up to each send, as it always was.
386
563
  const credential = !keyOnly && this.projectId && this.vercelOidcToken
@@ -391,10 +568,11 @@ export class DevTuneAiTrafficClient {
391
568
  }
392
569
  if (credential === null || credential === "drop-queue") {
393
570
  if (credential === null) {
394
- this.warnMissingVercelOidcTokenOnce(batch.length + this.queue.length);
571
+ this.warnMissingVercelOidcTokenOnce(batch.length + this.queuedEventCount());
395
572
  }
396
573
  this.queue.splice(0);
397
- return;
574
+ this.referralQueue.splice(0);
575
+ return false;
398
576
  }
399
577
  let response;
400
578
  try {
@@ -429,17 +607,29 @@ export class DevTuneAiTrafficClient {
429
607
  }
430
608
  break;
431
609
  }
432
- if (retryBudget === 0) {
610
+ if (retryBudgets[kind] === 0) {
433
611
  this.sampleWarn("DevTune AI Traffic ingest request failed", {
434
612
  attempts,
435
613
  droppedEvents: batch.length,
436
614
  status: response.status,
437
615
  });
438
- return;
616
+ if (kind === "bot") {
617
+ return true;
618
+ }
619
+ referralsStopped = true;
620
+ break;
439
621
  }
440
- const retryDelayMs = this.resolveRetryDelayMs(response.headers.get("retry-after"), this.maxRetryAttempts - retryBudget);
441
- retryBudget -= 1;
622
+ const retryDelayMs = this.resolveRetryDelayMs(response.headers.get("retry-after"), this.maxRetryAttempts - retryBudgets[kind]);
623
+ retryBudgets[kind] -= 1;
442
624
  await delay(retryDelayMs);
625
+ // The rate limit covers both kinds, so waiting bots go after the
626
+ // backoff rather than into the same 429.
627
+ if (kind === "referral" && this.queue.length > 0) {
628
+ this.referralQueue.unshift(...batch);
629
+ this.shedOverflow("referral");
630
+ requeued = { last: batch.at(-1), attempts };
631
+ break;
632
+ }
443
633
  }
444
634
  }
445
635
  }
@@ -490,7 +680,7 @@ export class DevTuneAiTrafficClient {
490
680
  if (this.keyCredential) {
491
681
  return this.keyCredential;
492
682
  }
493
- this.sampleWarn("DevTune AI Traffic dropped events: the Vercel OIDC token expired before it could be used", { droppedEvents: batchEvents + this.queue.length });
683
+ this.sampleWarn("DevTune AI Traffic dropped events: the Vercel OIDC token expired before it could be used", { droppedEvents: batchEvents + this.queuedEventCount() });
494
684
  return "drop-queue";
495
685
  }
496
686
  const earlierFailure = failedExchanges.get(token.value);
@@ -499,7 +689,7 @@ export class DevTuneAiTrafficClient {
499
689
  return this.keyCredential;
500
690
  }
501
691
  this.sampleWarn("DevTune AI Traffic could not exchange the Vercel OIDC token", {
502
- droppedEvents: batchEvents + this.queue.length,
692
+ droppedEvents: batchEvents + this.queuedEventCount(),
503
693
  ...earlierFailure,
504
694
  });
505
695
  return "drop-queue";
@@ -539,6 +729,9 @@ export class DevTuneAiTrafficClient {
539
729
  this.warnedMissingVercelOidcToken = true;
540
730
  this.logger?.warn("DevTune AI Traffic dropped events: projectId sends only from a Vercel deployment with OIDC enabled, and no ingestKey is set", { droppedEvents });
541
731
  }
732
+ queuedEventCount() {
733
+ return this.queue.length + this.referralQueue.length;
734
+ }
542
735
  resolveRetryDelayMs(retryAfterHeader, retryAttempts) {
543
736
  const retryAfterMs = parseRetryAfterMs(retryAfterHeader, this.now());
544
737
  const backoffMs = RETRY_BASE_DELAY_MS * 2 ** retryAttempts;
@@ -593,6 +786,69 @@ function readWebBotAuthSafely(request) {
593
786
  return null;
594
787
  }
595
788
  }
789
+ /** A cheap substring test, so a `Referer` naming no registry host is never parsed. */
790
+ function mentionsAnyHost(referer, hosts) {
791
+ for (const { hostname } of hosts) {
792
+ if (referer.includes(hostname)) {
793
+ return true;
794
+ }
795
+ }
796
+ return false;
797
+ }
798
+ /** A URL, only when it parses and is `http:` or `https:`. */
799
+ function parseHttpUrl(value) {
800
+ const url = value ? parseRequestUrl(value) : null;
801
+ return url && (url.protocol === "http:" || url.protocol === "https:") ? url : null;
802
+ }
803
+ /** The `Referer` cut to its origin, as DevTune's snippet keeps it. */
804
+ function readReferrerOrigin(referer) {
805
+ const origin = parseHttpUrl(referer)?.origin;
806
+ return origin && origin.length <= MAX_EVENT_REFERRER_LENGTH ? origin : null;
807
+ }
808
+ /**
809
+ * The campaign parameters DevTune's referral and paid-click rules read, and
810
+ * nothing else from the query. A value DevTune would not accept is left out
811
+ * rather than truncated: an over-long medium is never a paid one.
812
+ */
813
+ function readCampaign(url) {
814
+ const utmSource = readCampaignValue(url, "utm_source");
815
+ const utmMedium = readCampaignValue(url, "utm_medium");
816
+ const oppref = url.searchParams.has("oppref");
817
+ if (utmSource === null && utmMedium === null && !oppref) {
818
+ return null;
819
+ }
820
+ return {
821
+ ...(utmSource === null ? {} : { utm_source: utmSource }),
822
+ ...(utmMedium === null ? {} : { utm_medium: utmMedium }),
823
+ ...(oppref ? { oppref: true } : {}),
824
+ };
825
+ }
826
+ function readCampaignValue(url, name) {
827
+ const value = url.searchParams.get(name);
828
+ return value && value.length <= MAX_CAMPAIGN_VALUE_LENGTH ? value : null;
829
+ }
830
+ const CDN_HEADERS = ["cf-ray", "x-amz-cf-id", "fastly-client-ip", "fastly-ff", "fastly-ssl"];
831
+ /**
832
+ * Whether a CDN forwarded the request: Cloudflare's `cf-ray`, CloudFront's
833
+ * `x-amz-cf-id`, or any `fastly-*` header where the header names can be
834
+ * listed. A cache that adds none of them goes undetected.
835
+ */
836
+ function isBehindCdn(headers) {
837
+ try {
838
+ if (CDN_HEADERS.some((name) => headers.get(name) !== null)) {
839
+ return true;
840
+ }
841
+ for (const name of headers.keys?.() ?? []) {
842
+ if (name.toLowerCase().startsWith("fastly-")) {
843
+ return true;
844
+ }
845
+ }
846
+ }
847
+ catch {
848
+ // A custom headers object must not throw into the customer's request path.
849
+ }
850
+ return false;
851
+ }
596
852
  function scheduleBackground(promise, waitUntilContext) {
597
853
  const guardedPromise = promise.catch(() => undefined);
598
854
  if (waitUntilContext) {
@@ -783,7 +1039,4 @@ function truncateString(value, maxLength) {
783
1039
  }
784
1040
  return value.slice(0, maxLength);
785
1041
  }
786
- function isRecord(value) {
787
- return typeof value === "object" && value !== null;
788
- }
789
1042
  //# sourceMappingURL=client.js.map