@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/README.md +75 -12
- package/dist/cjs/client.js +326 -73
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/detection.js +14 -3
- package/dist/cjs/detection.js.map +1 -1
- package/dist/cjs/express.js +4 -0
- package/dist/cjs/express.js.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/node.js +4 -0
- package/dist/cjs/node.js.map +1 -1
- package/dist/cjs/referral.js +130 -0
- package/dist/cjs/referral.js.map +1 -0
- package/dist/cjs/registry.js +2 -0
- package/dist/cjs/registry.js.map +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/client.d.ts +139 -19
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +325 -72
- package/dist/client.js.map +1 -1
- package/dist/detection.d.ts +4 -0
- package/dist/detection.d.ts.map +1 -1
- package/dist/detection.js +12 -3
- package/dist/detection.js.map +1 -1
- package/dist/express.js +4 -0
- package/dist/express.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/node.js +4 -0
- package/dist/node.js.map +1 -1
- package/dist/referral.d.ts +39 -0
- package/dist/referral.d.ts.map +1 -0
- package/dist/referral.js +124 -0
- package/dist/referral.js.map +1 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +2 -2
- package/dist/registry.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
167
|
+
const source = userAgent ? this.matchSource(request, userAgent) : null;
|
|
168
|
+
if (!userAgent || !source) {
|
|
157
169
|
return null;
|
|
158
170
|
}
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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()
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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.
|
|
571
|
+
this.warnMissingVercelOidcTokenOnce(batch.length + this.queuedEventCount());
|
|
395
572
|
}
|
|
396
573
|
this.queue.splice(0);
|
|
397
|
-
|
|
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 (
|
|
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
|
-
|
|
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 -
|
|
441
|
-
|
|
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.
|
|
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.
|
|
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
|