@manototh/do11y 0.1.1 → 0.2.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/do11y.js +1033 -794
- package/dist/do11y.min.js +1 -1
- package/dist/instrumentation/index.js +1293 -0
- package/examples/do11y-config.example.js +1 -1
- package/package.json +29 -6
package/dist/do11y.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
(function() {
|
|
2
|
-
|
|
1
|
+
var Do11yBundle = (function(exports) {
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
//#region src/core/constants.ts
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
+
* Do11y — Documentation Observability
|
|
6
|
+
*
|
|
7
|
+
* OTel semantic convention attribute keys and event names.
|
|
8
|
+
*
|
|
5
9
|
* Standard attrs from https://opentelemetry.io/docs/specs/semconv/.
|
|
6
10
|
* Custom do11y attrs use the `browser.do11y.*` namespace.
|
|
7
11
|
*/
|
|
12
|
+
const VERSION = "0.2.0";
|
|
8
13
|
const ATTR_SESSION_ID = "session.id";
|
|
9
14
|
const ATTR_URL_PATH = "url.path";
|
|
10
15
|
const ATTR_URL_FRAGMENT = "url.fragment";
|
|
11
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Privacy-safe query parameter indicator. The actual query string is never
|
|
18
|
+
* sent. Emits `'has_params'` or `null` to indicate whether the URL contained
|
|
19
|
+
* query parameters.
|
|
20
|
+
*/
|
|
21
|
+
const ATTR_DO11Y_URL_HAS_PARAMS = "browser.do11y.url.has_params";
|
|
12
22
|
const ATTR_DEVICE_TYPE = "device.type";
|
|
13
23
|
const ATTR_BROWSER_FAMILY = "browser.family";
|
|
14
24
|
const ATTR_BROWSER_LANGUAGE = "browser.language";
|
|
@@ -52,9 +62,6 @@
|
|
|
52
62
|
const ATTR_DO11Y_EXPAND_SUMMARY = "browser.do11y.expand.summary";
|
|
53
63
|
const ATTR_DO11Y_EXPAND_ACTION = "browser.do11y.expand.action";
|
|
54
64
|
const ATTR_DO11Y_EXPAND_SECTION = "browser.do11y.expand.section";
|
|
55
|
-
/**
|
|
56
|
-
* OTel event names for do11y events (browser.do11y.* namespace).
|
|
57
|
-
*/
|
|
58
65
|
const EVENT_PAGE_VIEW = "browser.do11y.page_view";
|
|
59
66
|
const EVENT_PAGE_EXIT = "browser.do11y.page_exit";
|
|
60
67
|
const EVENT_SCROLL_DEPTH = "browser.do11y.scroll_depth";
|
|
@@ -66,59 +73,53 @@
|
|
|
66
73
|
const EVENT_TOC_CLICK = "browser.do11y.toc_click";
|
|
67
74
|
const EVENT_FEEDBACK = "browser.do11y.feedback";
|
|
68
75
|
const EVENT_EXPAND_COLLAPSE = "browser.do11y.expand_collapse";
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
codeBlockSelector: null,
|
|
117
|
-
navigationSelector: null,
|
|
118
|
-
footerSelector: null,
|
|
119
|
-
contentSelector: null,
|
|
120
|
-
useOtelBrowserInstrumentations: false
|
|
121
|
-
};
|
|
76
|
+
const SELECTOR_KEYS = [
|
|
77
|
+
"searchSelector",
|
|
78
|
+
"copyButtonSelector",
|
|
79
|
+
"codeBlockSelector",
|
|
80
|
+
"navigationSelector",
|
|
81
|
+
"footerSelector",
|
|
82
|
+
"contentSelector",
|
|
83
|
+
"tabContainerSelector",
|
|
84
|
+
"tocSelector",
|
|
85
|
+
"feedbackSelector"
|
|
86
|
+
];
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/core/rate-limit.ts
|
|
89
|
+
/**
|
|
90
|
+
* Do11y — Documentation Observability
|
|
91
|
+
*
|
|
92
|
+
* Shared event rate limiter used by both the standalone transport and the
|
|
93
|
+
* OTel instrumentation build.
|
|
94
|
+
*
|
|
95
|
+
* Rate-limiting prevents event spam (duplicate `page_exit` on SPA
|
|
96
|
+
* navigation, rapid same-type bursts). The rate-limit key is per event
|
|
97
|
+
* name, except for scroll depth milestones: a fast scroll can cross several
|
|
98
|
+
* thresholds in a single frame, so the key includes the threshold attribute
|
|
99
|
+
* to let each milestone through independently.
|
|
100
|
+
*/
|
|
101
|
+
function createRateLimiter() {
|
|
102
|
+
const lastEventTime = {};
|
|
103
|
+
return {
|
|
104
|
+
allow(eventName, eventData, rateLimitMs, debug) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const rateKey = eventData["browser.do11y.scroll.threshold"] !== null && eventData["browser.do11y.scroll.threshold"] !== void 0 ? `${eventName}:${String(eventData[ATTR_DO11Y_SCROLL_THRESHOLD])}` : eventName;
|
|
107
|
+
if (rateLimitMs > 0 && lastEventTime[rateKey]) {
|
|
108
|
+
if (now - lastEventTime[rateKey] < rateLimitMs) {
|
|
109
|
+
if (debug) console.log("[Do11y] Rate limited:", eventName);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
lastEventTime[rateKey] = now;
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
reset() {
|
|
117
|
+
for (const key of Object.keys(lastEventTime)) delete lastEventTime[key];
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/core/presets.ts
|
|
122
123
|
const FRAMEWORK_PRESETS = {
|
|
123
124
|
mintlify: {
|
|
124
125
|
searchSelector: "#search-bar-entry, #search-bar-entry-mobile, [class*=\"search\"]",
|
|
@@ -127,9 +128,9 @@
|
|
|
127
128
|
navigationSelector: "nav, [role=\"navigation\"], #navbar, #sidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
|
|
128
129
|
footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
|
|
129
130
|
contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
|
|
130
|
-
tabContainerSelector: "[role=\"tablist\"], [class*=\"tab\"]",
|
|
131
|
+
tabContainerSelector: "tabs, [role=\"tablist\"], [class*=\"tab\"]",
|
|
131
132
|
tocSelector: "#table-of-contents, [data-testid=\"table-of-contents\"], [class*=\"table-of-contents\"], [class*=\"toc\"]",
|
|
132
|
-
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
|
|
133
|
+
feedbackSelector: "feedback-toolbar, #feedback-thumbs-up, #feedback-thumbs-down, [class*=\"feedback\"], [class*=\"helpful\"]"
|
|
133
134
|
},
|
|
134
135
|
docusaurus: {
|
|
135
136
|
searchSelector: ".DocSearch, .DocSearch-Button",
|
|
@@ -198,23 +199,12 @@
|
|
|
198
199
|
feedbackSelector: ".feedback--answer, [class*=\"feedback\"], [class*=\"helpful\"]"
|
|
199
200
|
}
|
|
200
201
|
};
|
|
201
|
-
const SELECTOR_KEYS = [
|
|
202
|
-
"searchSelector",
|
|
203
|
-
"copyButtonSelector",
|
|
204
|
-
"codeBlockSelector",
|
|
205
|
-
"navigationSelector",
|
|
206
|
-
"footerSelector",
|
|
207
|
-
"contentSelector",
|
|
208
|
-
"tabContainerSelector",
|
|
209
|
-
"tocSelector",
|
|
210
|
-
"feedbackSelector"
|
|
211
|
-
];
|
|
212
202
|
/**
|
|
213
203
|
* Apply framework-specific selectors to the config.
|
|
214
204
|
* For 'custom', uses whatever the user set in config; for named
|
|
215
205
|
* frameworks, loads the preset and lets explicit config values override.
|
|
216
206
|
*/
|
|
217
|
-
function applyFrameworkSelectors() {
|
|
207
|
+
function applyFrameworkSelectors(config) {
|
|
218
208
|
const preset = FRAMEWORK_PRESETS[config.framework];
|
|
219
209
|
if (preset) SELECTOR_KEYS.forEach((key) => {
|
|
220
210
|
if (!config[key]) config[key] = preset[key];
|
|
@@ -228,22 +218,8 @@
|
|
|
228
218
|
if (!config[key]) config[key] = fallback[key];
|
|
229
219
|
});
|
|
230
220
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (config.debug) console.log("[Do11y] Disabled: Do Not Track is enabled");
|
|
234
|
-
return true;
|
|
235
|
-
}
|
|
236
|
-
if (config.allowedDomains && config.allowedDomains.length > 0) {
|
|
237
|
-
const currentDomain = window.location.hostname;
|
|
238
|
-
if (!config.allowedDomains.some((domain) => {
|
|
239
|
-
return currentDomain === domain || currentDomain.endsWith("." + domain);
|
|
240
|
-
})) {
|
|
241
|
-
if (config.debug) console.log("[Do11y] Disabled: Domain not allowed:", currentDomain);
|
|
242
|
-
return true;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/core/privacy.ts
|
|
247
223
|
/**
|
|
248
224
|
* Validate a CSS selector string supplied through user configuration.
|
|
249
225
|
* Returns the selector unchanged if it is syntactically valid, or null
|
|
@@ -257,10 +233,27 @@
|
|
|
257
233
|
document.querySelector(selector);
|
|
258
234
|
return selector;
|
|
259
235
|
} catch {
|
|
260
|
-
if (config.debug) console.warn("[Do11y] Invalid CSS selector rejected:", selector);
|
|
261
236
|
return null;
|
|
262
237
|
}
|
|
263
238
|
}
|
|
239
|
+
function shouldDisableTracking(config) {
|
|
240
|
+
if (config.respectDNT && (navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || window.doNotTrack === "1")) {
|
|
241
|
+
if (config.debug) console.log("[Do11y] Disabled: Do Not Track is enabled");
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (config.allowedDomains && config.allowedDomains.length > 0) {
|
|
245
|
+
const currentDomain = window.location.hostname;
|
|
246
|
+
if (!config.allowedDomains.some((domain) => {
|
|
247
|
+
return currentDomain === domain || currentDomain.endsWith("." + domain);
|
|
248
|
+
})) {
|
|
249
|
+
if (config.debug) console.log("[Do11y] Disabled: Domain not allowed:", currentDomain);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/core/dom-utils.ts
|
|
264
257
|
function getElementClassName(el) {
|
|
265
258
|
if (typeof el.className === "string") return el.className;
|
|
266
259
|
const svgClass = el.className;
|
|
@@ -309,12 +302,41 @@
|
|
|
309
302
|
if (!pathPart || pathPart === window.location.pathname || pathPart === `${window.location.pathname}${window.location.search}`) return href.slice(hashIndex);
|
|
310
303
|
return null;
|
|
311
304
|
}
|
|
312
|
-
function resolveTocContainer(link) {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
305
|
+
function resolveTocContainer(link, config) {
|
|
306
|
+
const userSelector = validateSelector(config.tocSelector);
|
|
307
|
+
if (userSelector) {
|
|
308
|
+
const container = link.closest(userSelector);
|
|
309
|
+
if (container && container !== link && container.tagName !== "A") return container;
|
|
310
|
+
}
|
|
311
|
+
for (const sel of [
|
|
312
|
+
".VPDocAsideOutline",
|
|
313
|
+
".VPLocalNavOutlineDropdown",
|
|
314
|
+
".table-of-contents",
|
|
315
|
+
".right-sidebar-panel",
|
|
316
|
+
"starlight-toc",
|
|
317
|
+
"[class*=\"TableOfContents\"]",
|
|
318
|
+
"[class*=\"page-outline\"]",
|
|
319
|
+
"[class*=\"toc\"]",
|
|
320
|
+
"nav[id=\"TableOfContents\"]"
|
|
321
|
+
]) {
|
|
322
|
+
const container = link.closest(sel);
|
|
323
|
+
if (container && container !== link && container.tagName !== "A") return container;
|
|
324
|
+
}
|
|
325
|
+
return link.parentElement && link.parentElement !== document.body ? link.parentElement : null;
|
|
326
|
+
}
|
|
327
|
+
function getNearestHeading(element) {
|
|
328
|
+
let current = element;
|
|
329
|
+
while (current && current !== document.body) {
|
|
330
|
+
let sibling = current.previousElementSibling;
|
|
331
|
+
while (sibling) {
|
|
332
|
+
if (/^H[1-6]$/.test(sibling.tagName)) return sibling.textContent?.trim().substring(0, 100) ?? null;
|
|
333
|
+
const headings = sibling.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
334
|
+
if (headings.length > 0) return headings[headings.length - 1].textContent?.trim().substring(0, 100) ?? null;
|
|
335
|
+
sibling = sibling.previousElementSibling;
|
|
336
|
+
}
|
|
337
|
+
current = current.parentElement;
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
318
340
|
}
|
|
319
341
|
function sanitizeText(text, maxLength) {
|
|
320
342
|
if (!text || typeof text !== "string") return null;
|
|
@@ -329,71 +351,8 @@
|
|
|
329
351
|
sanitized = sanitized.replace(/\b[0-9a-fA-F]{32,}\b/g, "[redacted]");
|
|
330
352
|
return sanitized.trim().substring(0, limit);
|
|
331
353
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
|
335
|
-
const arr = new Uint8Array(16);
|
|
336
|
-
window.crypto.getRandomValues(arr);
|
|
337
|
-
arr[6] = arr[6] & 15 | 64;
|
|
338
|
-
arr[8] = arr[8] & 63 | 128;
|
|
339
|
-
const hex = Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
340
|
-
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
|
|
341
|
-
}
|
|
342
|
-
return "no-crypto-00-0000-0000-000000000000";
|
|
343
|
-
}
|
|
344
|
-
function isValidSessionData(value) {
|
|
345
|
-
if (!value || typeof value !== "object") return false;
|
|
346
|
-
const v = value;
|
|
347
|
-
return typeof v.id === "string" && v.id.length > 0 && typeof v.startTime === "string" && Array.isArray(v.pageSequence) && typeof v.pageCount === "number";
|
|
348
|
-
}
|
|
349
|
-
function getSession() {
|
|
350
|
-
let session = null;
|
|
351
|
-
try {
|
|
352
|
-
const stored = sessionStorage.getItem("do11y_session");
|
|
353
|
-
if (stored) {
|
|
354
|
-
const parsed = JSON.parse(stored);
|
|
355
|
-
if (isValidSessionData(parsed)) session = parsed;
|
|
356
|
-
}
|
|
357
|
-
} catch {}
|
|
358
|
-
if (!session) {
|
|
359
|
-
session = {
|
|
360
|
-
id: generateSessionId(),
|
|
361
|
-
startTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
362
|
-
pageSequence: [],
|
|
363
|
-
pageCount: 0,
|
|
364
|
-
referrerCategory: null,
|
|
365
|
-
aiPlatform: null
|
|
366
|
-
};
|
|
367
|
-
saveSession(session);
|
|
368
|
-
}
|
|
369
|
-
return session;
|
|
370
|
-
}
|
|
371
|
-
function saveSession(session) {
|
|
372
|
-
try {
|
|
373
|
-
sessionStorage.setItem("do11y_session", JSON.stringify(session));
|
|
374
|
-
} catch {}
|
|
375
|
-
}
|
|
376
|
-
function updatePageSequence(path) {
|
|
377
|
-
const session = getSession();
|
|
378
|
-
session.pageCount++;
|
|
379
|
-
session.pageSequence.push({
|
|
380
|
-
path,
|
|
381
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
382
|
-
index: session.pageCount
|
|
383
|
-
});
|
|
384
|
-
if (session.pageSequence.length > 50) session.pageSequence = session.pageSequence.slice(-50);
|
|
385
|
-
saveSession(session);
|
|
386
|
-
return session;
|
|
387
|
-
}
|
|
388
|
-
function getBrowserContext() {
|
|
389
|
-
return {
|
|
390
|
-
[ATTR_DO11Y_VIEWPORT_CATEGORY]: categorizeViewport(),
|
|
391
|
-
[ATTR_BROWSER_FAMILY]: getBrowserFamily(),
|
|
392
|
-
[ATTR_DEVICE_TYPE]: getDeviceType(),
|
|
393
|
-
[ATTR_BROWSER_LANGUAGE]: (navigator.language || "").split("-")[0] || "unknown",
|
|
394
|
-
[ATTR_DO11Y_TIMEZONE_OFFSET]: (/* @__PURE__ */ new Date()).getTimezoneOffset() / 60
|
|
395
|
-
};
|
|
396
|
-
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/core/context.ts
|
|
397
356
|
function categorizeViewport() {
|
|
398
357
|
const width = window.innerWidth;
|
|
399
358
|
if (width < 640) return "mobile";
|
|
@@ -417,6 +376,15 @@
|
|
|
417
376
|
}
|
|
418
377
|
return "desktop";
|
|
419
378
|
}
|
|
379
|
+
function getBrowserContext() {
|
|
380
|
+
return {
|
|
381
|
+
[ATTR_DO11Y_VIEWPORT_CATEGORY]: categorizeViewport(),
|
|
382
|
+
[ATTR_BROWSER_FAMILY]: getBrowserFamily(),
|
|
383
|
+
[ATTR_DEVICE_TYPE]: getDeviceType(),
|
|
384
|
+
[ATTR_BROWSER_LANGUAGE]: (navigator.language || "").split("-")[0] || "unknown",
|
|
385
|
+
[ATTR_DO11Y_TIMEZONE_OFFSET]: (/* @__PURE__ */ new Date()).getTimezoneOffset() / 60
|
|
386
|
+
};
|
|
387
|
+
}
|
|
420
388
|
/**
|
|
421
389
|
* Known AI platform referrer patterns.
|
|
422
390
|
* Each entry maps a substring found in the referrer hostname to an AI
|
|
@@ -542,272 +510,318 @@
|
|
|
542
510
|
return {
|
|
543
511
|
[ATTR_URL_PATH]: window.location.pathname,
|
|
544
512
|
[ATTR_URL_FRAGMENT]: window.location.hash || null,
|
|
545
|
-
[
|
|
513
|
+
[ATTR_DO11Y_URL_HAS_PARAMS]: window.location.search ? "has_params" : null,
|
|
546
514
|
[ATTR_DO11Y_PAGE_TITLE]: sanitizeText(document.title, 150)
|
|
547
515
|
};
|
|
548
516
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
lastEventTime[eventName] = now;
|
|
563
|
-
const session = getSession();
|
|
564
|
-
const event = {
|
|
565
|
-
_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
566
|
-
eventName,
|
|
567
|
-
[ATTR_DO11Y_DO11Y_VERSION]: VERSION,
|
|
568
|
-
[ATTR_SESSION_ID]: session.id,
|
|
569
|
-
[ATTR_DO11Y_SESSION_PAGE_COUNT]: session.pageCount,
|
|
570
|
-
...getPageInfo(),
|
|
571
|
-
...getBrowserContext(),
|
|
572
|
-
...eventData
|
|
573
|
-
};
|
|
574
|
-
if (config.debug) console.log("[Do11y] Event queued:", eventName, event);
|
|
575
|
-
if (config.destination === "otlp" && _otelLogger) {
|
|
576
|
-
_otelLogger.emit({
|
|
577
|
-
eventName,
|
|
578
|
-
severityNumber: 9,
|
|
579
|
-
attributes: event,
|
|
580
|
-
body: ""
|
|
581
|
-
});
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
eventQueue.push(event);
|
|
585
|
-
if (eventQueue.length > 100) {
|
|
586
|
-
eventQueue = eventQueue.slice(-100);
|
|
587
|
-
if (config.debug) console.warn("[Do11y] Event queue capped at 100 events");
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region src/core/session.ts
|
|
519
|
+
function generateSessionId() {
|
|
520
|
+
if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
|
|
521
|
+
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
|
522
|
+
const arr = new Uint8Array(16);
|
|
523
|
+
window.crypto.getRandomValues(arr);
|
|
524
|
+
arr[6] = arr[6] & 15 | 64;
|
|
525
|
+
arr[8] = arr[8] & 63 | 128;
|
|
526
|
+
const hex = Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
527
|
+
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
|
|
588
528
|
}
|
|
589
|
-
|
|
590
|
-
else scheduleFlush();
|
|
529
|
+
return "no-crypto-00-0000-0000-000000000000";
|
|
591
530
|
}
|
|
592
|
-
function
|
|
593
|
-
if (
|
|
594
|
-
|
|
531
|
+
function isValidSessionData(value) {
|
|
532
|
+
if (!value || typeof value !== "object") return false;
|
|
533
|
+
const v = value;
|
|
534
|
+
return typeof v.id === "string" && v.id.length > 0 && typeof v.startTime === "string" && Array.isArray(v.pageSequence) && typeof v.pageCount === "number";
|
|
595
535
|
}
|
|
596
|
-
|
|
597
|
-
|
|
536
|
+
function getSession() {
|
|
537
|
+
let session = null;
|
|
598
538
|
try {
|
|
599
|
-
const
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
539
|
+
const stored = sessionStorage.getItem("do11y_session");
|
|
540
|
+
if (stored) {
|
|
541
|
+
const parsed = JSON.parse(stored);
|
|
542
|
+
if (isValidSessionData(parsed)) session = parsed;
|
|
543
|
+
}
|
|
544
|
+
} catch {}
|
|
545
|
+
if (!session) {
|
|
546
|
+
session = {
|
|
547
|
+
id: generateSessionId(),
|
|
548
|
+
startTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
549
|
+
pageSequence: [],
|
|
550
|
+
pageCount: 0,
|
|
551
|
+
referrerCategory: null,
|
|
552
|
+
aiPlatform: null
|
|
553
|
+
};
|
|
554
|
+
saveSession(session);
|
|
605
555
|
}
|
|
556
|
+
return session;
|
|
606
557
|
}
|
|
607
|
-
function
|
|
558
|
+
function saveSession(session) {
|
|
608
559
|
try {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
560
|
+
sessionStorage.setItem("do11y_session", JSON.stringify(session));
|
|
561
|
+
} catch {}
|
|
562
|
+
}
|
|
563
|
+
function updatePageSequence(path) {
|
|
564
|
+
const session = getSession();
|
|
565
|
+
session.pageCount++;
|
|
566
|
+
session.pageSequence.push({
|
|
567
|
+
path,
|
|
568
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
569
|
+
index: session.pageCount
|
|
570
|
+
});
|
|
571
|
+
if (session.pageSequence.length > 50) session.pageSequence = session.pageSequence.slice(-50);
|
|
572
|
+
saveSession(session);
|
|
573
|
+
return session;
|
|
574
|
+
}
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/core/tracking/scroll.ts
|
|
577
|
+
let trackedScrollDepths = /* @__PURE__ */ new Set();
|
|
578
|
+
let scrollContainer = null;
|
|
579
|
+
function findScrollableAncestor(el) {
|
|
580
|
+
let current = el;
|
|
581
|
+
while (current && current !== document.body && current !== document.documentElement) {
|
|
582
|
+
const overflowY = window.getComputedStyle(current).overflowY;
|
|
583
|
+
if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
584
|
+
current = current.parentElement;
|
|
617
585
|
}
|
|
586
|
+
return null;
|
|
618
587
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
588
|
+
/**
|
|
589
|
+
* Check and track scroll depth thresholds.
|
|
590
|
+
* Reads from the detected scroll container when present, otherwise
|
|
591
|
+
* falls back to the window/document.
|
|
592
|
+
*
|
|
593
|
+
* If the page fits entirely in the viewport (no scrollbar), all
|
|
594
|
+
* thresholds are marked as reached since the user can see 100% of
|
|
595
|
+
* the content without scrolling.
|
|
596
|
+
*/
|
|
597
|
+
function checkScrollDepth(config, emit) {
|
|
598
|
+
let scrollTop;
|
|
599
|
+
let totalHeight;
|
|
600
|
+
let viewportHeight;
|
|
601
|
+
if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) {
|
|
602
|
+
scrollTop = scrollContainer.scrollTop;
|
|
603
|
+
totalHeight = scrollContainer.scrollHeight;
|
|
604
|
+
viewportHeight = scrollContainer.clientHeight;
|
|
605
|
+
} else {
|
|
606
|
+
scrollTop = window.scrollY || document.documentElement.scrollTop;
|
|
607
|
+
totalHeight = document.documentElement.scrollHeight;
|
|
608
|
+
viewportHeight = window.innerHeight;
|
|
638
609
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
610
|
+
const docHeight = totalHeight - viewportHeight;
|
|
611
|
+
if (docHeight <= 0) {
|
|
612
|
+
config.scrollThresholds.forEach((threshold) => {
|
|
613
|
+
if (!trackedScrollDepths.has(threshold)) {
|
|
614
|
+
trackedScrollDepths.add(threshold);
|
|
615
|
+
emit(EVENT_SCROLL_DEPTH, {
|
|
616
|
+
[ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
|
|
617
|
+
[ATTR_DO11Y_SCROLL_PERCENT]: 100
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const scrollPercent = Math.round(scrollTop / docHeight * 100);
|
|
624
|
+
config.scrollThresholds.forEach((threshold) => {
|
|
625
|
+
if (scrollPercent >= threshold && !trackedScrollDepths.has(threshold)) {
|
|
626
|
+
trackedScrollDepths.add(threshold);
|
|
627
|
+
emit(EVENT_SCROLL_DEPTH, {
|
|
628
|
+
[ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
|
|
629
|
+
[ATTR_DO11Y_SCROLL_PERCENT]: scrollPercent
|
|
630
|
+
});
|
|
643
631
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
function setupScrollTracking(config, emit) {
|
|
635
|
+
if (!config.trackScrollDepth) return;
|
|
636
|
+
if (config.contentSelector) {
|
|
637
|
+
const contentEl = document.querySelector(config.contentSelector);
|
|
638
|
+
if (contentEl) scrollContainer = findScrollableAncestor(contentEl);
|
|
639
|
+
}
|
|
640
|
+
let ticking = false;
|
|
641
|
+
function onScroll() {
|
|
642
|
+
if (!ticking) {
|
|
643
|
+
window.requestAnimationFrame(() => {
|
|
644
|
+
checkScrollDepth(config, emit);
|
|
645
|
+
ticking = false;
|
|
646
|
+
});
|
|
647
|
+
ticking = true;
|
|
647
648
|
}
|
|
648
|
-
return true;
|
|
649
649
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
650
|
+
window.addEventListener("scroll", onScroll);
|
|
651
|
+
if (scrollContainer) {
|
|
652
|
+
scrollContainer.addEventListener("scroll", onScroll);
|
|
653
|
+
if (config.debug) {
|
|
654
|
+
const sc = scrollContainer;
|
|
655
|
+
console.log("[do11y] Using container-based scroll tracking:", sc.className || sc.tagName);
|
|
654
656
|
}
|
|
655
|
-
initOtelSdk().catch((err) => {
|
|
656
|
-
if (config.debug) console.warn("[Do11y] OTel SDK initialization failed:", err);
|
|
657
|
-
});
|
|
658
|
-
return true;
|
|
659
657
|
}
|
|
660
|
-
|
|
661
|
-
return false;
|
|
658
|
+
checkScrollDepth(config, emit);
|
|
662
659
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
)
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
`${cdnBase}@opentelemetry/sdk-logs`
|
|
677
|
-
);
|
|
678
|
-
const otlpExporter = await import(
|
|
679
|
-
/* @vite-ignore */
|
|
680
|
-
`${cdnBase}@opentelemetry/exporter-logs-otlp-http`
|
|
681
|
-
);
|
|
682
|
-
const resourceAttrs = {
|
|
683
|
-
"service.name": config.otelSdkServiceName || "do11y",
|
|
684
|
-
"service.version": VERSION,
|
|
685
|
-
"telemetry.sdk.name": "do11y",
|
|
686
|
-
"telemetry.sdk.language": "webjs",
|
|
687
|
-
"telemetry.sdk.version": VERSION,
|
|
688
|
-
...config.otelSdkResourceAttributes
|
|
689
|
-
};
|
|
690
|
-
const loggerProvider = new sdkLogs.LoggerProvider({
|
|
691
|
-
resource: { attributes: resourceAttrs },
|
|
692
|
-
processors: [new sdkLogs.BatchLogRecordProcessor({ exporter: new otlpExporter.OTLPLogExporter({
|
|
693
|
-
url: config.otelSdkEndpoint.replace(/\/$/, "") + "/v1/logs",
|
|
694
|
-
headers: config.otelSdkHeaders
|
|
695
|
-
}) })]
|
|
660
|
+
function resetTrackedScrollDepths() {
|
|
661
|
+
trackedScrollDepths = /* @__PURE__ */ new Set();
|
|
662
|
+
}
|
|
663
|
+
function getTrackedScrollDepths() {
|
|
664
|
+
return trackedScrollDepths;
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/core/tracking/sections.ts
|
|
668
|
+
function emitSectionEvent(emit, el, elapsedMs) {
|
|
669
|
+
emit(EVENT_SECTION_VISIBLE, {
|
|
670
|
+
[ATTR_DO11Y_SECTION_HEADING]: sanitizeText(el.textContent?.trim() ?? "", 100),
|
|
671
|
+
[ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(el.tagName.charAt(1), 10),
|
|
672
|
+
[ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsedMs / 1e3)
|
|
696
673
|
});
|
|
697
|
-
apiLogs.logs.setGlobalLoggerProvider(loggerProvider);
|
|
698
|
-
_otelLogger = loggerProvider.getLogger("do11y");
|
|
699
|
-
if (config.debug) console.log("[Do11y] OTel SDK initialized with endpoint:", config.otelSdkEndpoint);
|
|
700
674
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
675
|
+
let sectionObserver = null;
|
|
676
|
+
let sectionTimers = {};
|
|
677
|
+
function setupSectionVisibilityTracking(config, emit) {
|
|
678
|
+
if (!config.trackSectionVisibility) return;
|
|
679
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
680
|
+
const threshold = config.sectionVisibleThreshold * 1e3;
|
|
681
|
+
sectionObserver = new IntersectionObserver((entries) => {
|
|
682
|
+
entries.forEach((entry) => {
|
|
683
|
+
const id = entry.target.getAttribute("data-do11y-section-id");
|
|
684
|
+
if (!id) return;
|
|
685
|
+
if (entry.isIntersecting) {
|
|
686
|
+
if (!sectionTimers[id]) {
|
|
687
|
+
const timer = {
|
|
688
|
+
start: Date.now(),
|
|
689
|
+
reported: false,
|
|
690
|
+
timeoutId: null
|
|
691
|
+
};
|
|
692
|
+
timer.timeoutId = setTimeout(() => {
|
|
693
|
+
if (sectionTimers[id] && !sectionTimers[id].reported) {
|
|
694
|
+
emitSectionEvent(emit, entry.target, threshold);
|
|
695
|
+
sectionTimers[id].reported = true;
|
|
696
|
+
}
|
|
697
|
+
}, threshold);
|
|
698
|
+
sectionTimers[id] = timer;
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
if (sectionTimers[id]) {
|
|
702
|
+
if (sectionTimers[id].timeoutId) clearTimeout(sectionTimers[id].timeoutId);
|
|
703
|
+
if (!sectionTimers[id].reported) {
|
|
704
|
+
const elapsed = Date.now() - sectionTimers[id].start;
|
|
705
|
+
if (elapsed >= threshold) {
|
|
706
|
+
emitSectionEvent(emit, entry.target, elapsed);
|
|
707
|
+
sectionTimers[id].reported = true;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
delete sectionTimers[id];
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}, { threshold: .5 });
|
|
715
|
+
observeHeadings();
|
|
725
716
|
}
|
|
726
|
-
function
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
|
|
717
|
+
function observeHeadings() {
|
|
718
|
+
if (!sectionObserver) return;
|
|
719
|
+
document.querySelectorAll("h2, h3").forEach((h, i) => {
|
|
720
|
+
h.setAttribute("data-do11y-section-id", "section-" + i);
|
|
721
|
+
sectionObserver.observe(h);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function flushVisibleSections(config, emit) {
|
|
725
|
+
if (!sectionObserver) return;
|
|
726
|
+
const now = Date.now();
|
|
727
|
+
const threshold = config.sectionVisibleThreshold * 1e3;
|
|
728
|
+
Object.keys(sectionTimers).forEach((id) => {
|
|
729
|
+
const timer = sectionTimers[id];
|
|
730
|
+
if (timer && !timer.reported) {
|
|
731
|
+
if (timer.timeoutId) clearTimeout(timer.timeoutId);
|
|
732
|
+
const elapsed = now - timer.start;
|
|
733
|
+
if (elapsed >= threshold) {
|
|
734
|
+
const escapedId = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~ ]/g, "\\$&");
|
|
735
|
+
const el = document.querySelector("[data-do11y-section-id=\"" + escapedId + "\"]");
|
|
736
|
+
if (el) emitSectionEvent(emit, el, elapsed);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
sectionTimers = {};
|
|
741
|
+
}
|
|
742
|
+
function disconnectSectionObserver() {
|
|
743
|
+
if (sectionObserver) {
|
|
744
|
+
if (sectionTimers && Object.keys(sectionTimers).length > 0) {
|
|
745
|
+
Object.keys(sectionTimers).forEach((id) => {
|
|
746
|
+
const timer = sectionTimers[id];
|
|
747
|
+
if (timer && !timer.reported) {
|
|
748
|
+
if (timer.timeoutId) clearTimeout(timer.timeoutId);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
sectionTimers = {};
|
|
752
|
+
}
|
|
753
|
+
sectionObserver.disconnect();
|
|
754
|
+
sectionObserver = null;
|
|
730
755
|
}
|
|
731
|
-
if (eventQueue.length === 0) return;
|
|
732
|
-
if (!validateConfig()) return;
|
|
733
|
-
const retries = typeof retriesLeft === "number" ? retriesLeft : config.maxRetries;
|
|
734
|
-
const events = eventQueue.slice();
|
|
735
|
-
eventQueue = [];
|
|
736
|
-
sendEvents(buildRequest(events), events, retries);
|
|
737
756
|
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/core/tracking/engagement.ts
|
|
759
|
+
let pageLoadTime = Date.now();
|
|
760
|
+
let lastActivityTime = Date.now();
|
|
761
|
+
let totalActiveTime = 0;
|
|
762
|
+
let isPageVisible = true;
|
|
763
|
+
let pageExited = false;
|
|
738
764
|
/**
|
|
739
|
-
*
|
|
765
|
+
* @param afterEmit Optional callback invoked after the exit event is emitted.
|
|
766
|
+
* Used by the standalone build to flush the transport before the page unloads.
|
|
740
767
|
*/
|
|
741
|
-
function
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
768
|
+
function emitPageExit(config, emit, afterEmit) {
|
|
769
|
+
if (pageExited) return;
|
|
770
|
+
pageExited = true;
|
|
771
|
+
if (isPageVisible) totalActiveTime += Date.now() - lastActivityTime;
|
|
772
|
+
const totalTime = Date.now() - pageLoadTime;
|
|
773
|
+
const engagementRatio = totalTime > 0 ? totalActiveTime / totalTime : 0;
|
|
774
|
+
let maxScroll = 0;
|
|
775
|
+
getTrackedScrollDepths().forEach((depth) => {
|
|
776
|
+
if (depth > maxScroll) maxScroll = depth;
|
|
777
|
+
});
|
|
778
|
+
flushVisibleSections(config, emit);
|
|
779
|
+
const session = getSession();
|
|
780
|
+
emit(EVENT_PAGE_EXIT, {
|
|
781
|
+
[ATTR_DO11Y_TOTAL_TIME_SECONDS]: Math.round(totalTime / 1e3),
|
|
782
|
+
[ATTR_DO11Y_ACTIVE_TIME_SECONDS]: Math.round(totalActiveTime / 1e3),
|
|
783
|
+
[ATTR_DO11Y_ENGAGEMENT_RATIO]: Math.round(engagementRatio * 100) / 100,
|
|
784
|
+
[ATTR_DO11Y_MAX_SCROLL_DEPTH]: maxScroll,
|
|
785
|
+
[ATTR_DO11Y_REFERRER_CATEGORY]: session.referrerCategory,
|
|
786
|
+
[ATTR_DO11Y_AI_PLATFORM]: session.aiPlatform
|
|
787
|
+
});
|
|
788
|
+
afterEmit?.();
|
|
747
789
|
}
|
|
748
|
-
function
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
body: req.body,
|
|
755
|
-
keepalive: true,
|
|
756
|
-
mode: crossOrigin ? "cors" : "same-origin"
|
|
757
|
-
}).then((response) => {
|
|
758
|
-
if (response.ok) {
|
|
759
|
-
if (config.debug) console.log("[Do11y] Flushed", events.length, "events");
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
if (retriesLeft > 0 && (response.status >= 500 || response.status === 429)) {
|
|
763
|
-
if (config.debug) console.log("[Do11y] Retrying after error:", response.status);
|
|
764
|
-
eventQueue = events.concat(eventQueue);
|
|
765
|
-
setTimeout(() => {
|
|
766
|
-
flush(retriesLeft - 1);
|
|
767
|
-
}, config.retryDelay * (config.maxRetries - retriesLeft + 1));
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
if (config.debug) response.text().then((text) => {
|
|
771
|
-
const msg = `[Do11y] Ingest failed: ${response.status}`;
|
|
772
|
-
if (response.status === 0 && response.type === "opaque") console.error(msg, "- CORS error: server did not return Access-Control-Allow-Origin");
|
|
773
|
-
else console.error(msg, text);
|
|
774
|
-
}).catch(() => {});
|
|
775
|
-
}).catch((err) => {
|
|
776
|
-
if (retriesLeft > 0) {
|
|
777
|
-
if (config.debug) {
|
|
778
|
-
const hint = crossOrigin ? " (this may be a CORS issue — try using an OTel Collector proxy)" : "";
|
|
779
|
-
console.log("[Do11y] Network error, retrying:", err.message + hint);
|
|
790
|
+
function setupEngagementTracking(config, emit) {
|
|
791
|
+
document.addEventListener("visibilitychange", () => {
|
|
792
|
+
if (document.hidden) {
|
|
793
|
+
if (isPageVisible) {
|
|
794
|
+
totalActiveTime += Date.now() - lastActivityTime;
|
|
795
|
+
isPageVisible = false;
|
|
780
796
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
797
|
+
} else {
|
|
798
|
+
lastActivityTime = Date.now();
|
|
799
|
+
isPageVisible = true;
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
window.addEventListener("beforeunload", () => {
|
|
803
|
+
emitPageExit(config, emit);
|
|
786
804
|
});
|
|
787
805
|
}
|
|
806
|
+
function resetEngagementState() {
|
|
807
|
+
pageLoadTime = Date.now();
|
|
808
|
+
lastActivityTime = Date.now();
|
|
809
|
+
totalActiveTime = 0;
|
|
810
|
+
isPageVisible = true;
|
|
811
|
+
pageExited = false;
|
|
812
|
+
}
|
|
788
813
|
/**
|
|
789
|
-
*
|
|
790
|
-
*
|
|
814
|
+
* Reset only the page_exit guard flag, without affecting timing data.
|
|
815
|
+
* Called by trackPageView() so that the guard is cleared even if
|
|
816
|
+
* resetEngagementState() (which also resets it) was not invoked.
|
|
791
817
|
*/
|
|
792
|
-
function
|
|
793
|
-
if (config.destination === "otlp") return;
|
|
794
|
-
if (eventQueue.length === 0) return;
|
|
795
|
-
if (!validateConfig()) return;
|
|
796
|
-
const events = eventQueue;
|
|
797
|
-
eventQueue = [];
|
|
798
|
-
const req = buildRequest(events);
|
|
799
|
-
try {
|
|
800
|
-
fetch(req.url, {
|
|
801
|
-
method: "POST",
|
|
802
|
-
headers: req.headers,
|
|
803
|
-
body: req.body,
|
|
804
|
-
keepalive: true
|
|
805
|
-
});
|
|
806
|
-
} catch {}
|
|
807
|
-
if (config.debug) console.log("[Do11y] Sync flushed", events.length, "events");
|
|
808
|
-
}
|
|
809
|
-
function trackPageView() {
|
|
818
|
+
function resetPageExitedGuard() {
|
|
810
819
|
pageExited = false;
|
|
820
|
+
}
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region src/core/tracking/page-view.ts
|
|
823
|
+
function trackPageView(config, emit) {
|
|
824
|
+
resetPageExitedGuard();
|
|
811
825
|
const session = updatePageSequence(window.location.pathname);
|
|
812
826
|
const referrerDomain = getReferrerDomain();
|
|
813
827
|
const referrerInfo = classifyReferrer(referrerDomain);
|
|
@@ -816,7 +830,7 @@
|
|
|
816
830
|
session.aiPlatform = referrerInfo.aiPlatform;
|
|
817
831
|
saveSession(session);
|
|
818
832
|
}
|
|
819
|
-
|
|
833
|
+
emit(EVENT_PAGE_VIEW, {
|
|
820
834
|
[ATTR_DO11Y_REFERRER_DOMAIN]: referrerDomain,
|
|
821
835
|
[ATTR_DO11Y_REFERRER_CATEGORY]: referrerInfo.referrerCategory,
|
|
822
836
|
[ATTR_DO11Y_AI_PLATFORM]: referrerInfo.aiPlatform,
|
|
@@ -824,7 +838,37 @@
|
|
|
824
838
|
[ATTR_DO11Y_PREVIOUS_PATH]: session.pageSequence.length > 1 ? session.pageSequence[session.pageSequence.length - 2].path : null
|
|
825
839
|
});
|
|
826
840
|
}
|
|
827
|
-
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region src/core/tracking/links.ts
|
|
843
|
+
function getLinkContext(link, config) {
|
|
844
|
+
if (link.closest(config.navigationSelector)) return "navigation";
|
|
845
|
+
if (link.closest(config.footerSelector)) return "footer";
|
|
846
|
+
if (link.closest(config.contentSelector)) return "content";
|
|
847
|
+
return "other";
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Pre-compute same-href indices for all `<a>` elements on the page.
|
|
851
|
+
* This avoids O(n) querySelectorAll calls on every click.
|
|
852
|
+
* Data attributes are set at init time and read directly on click.
|
|
853
|
+
*/
|
|
854
|
+
function precomputeLinkIndices() {
|
|
855
|
+
try {
|
|
856
|
+
const linkGroups = /* @__PURE__ */ new Map();
|
|
857
|
+
document.querySelectorAll("a[href]").forEach((link) => {
|
|
858
|
+
const href = link.getAttribute("href") ?? "";
|
|
859
|
+
const group = linkGroups.get(href) ?? [];
|
|
860
|
+
group.push(link);
|
|
861
|
+
linkGroups.set(href, group);
|
|
862
|
+
});
|
|
863
|
+
linkGroups.forEach((links) => {
|
|
864
|
+
links.forEach((link, idx) => {
|
|
865
|
+
link.setAttribute("data-do11y-link-idx", String(idx + 1));
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
} catch {}
|
|
869
|
+
}
|
|
870
|
+
function setupLinkTracking(config, emit) {
|
|
871
|
+
precomputeLinkIndices();
|
|
828
872
|
document.addEventListener("click", (e) => {
|
|
829
873
|
const link = e.target.closest("a");
|
|
830
874
|
if (!link) return;
|
|
@@ -846,370 +890,555 @@
|
|
|
846
890
|
} catch {}
|
|
847
891
|
if (linkType === "internal" && !config.trackInternalLinks) return;
|
|
848
892
|
if (linkType === "external" && !config.trackOutboundLinks) return;
|
|
849
|
-
|
|
893
|
+
const linkIndex = parseInt(link.getAttribute("data-do11y-link-idx") ?? "1", 10);
|
|
894
|
+
emit(EVENT_LINK_CLICK, {
|
|
850
895
|
[ATTR_DO11Y_LINK_TYPE]: linkType,
|
|
851
896
|
[ATTR_DO11Y_LINK_TARGET_URL]: href,
|
|
852
897
|
[ATTR_DO11Y_LINK_TARGET_DOMAIN]: targetDomain,
|
|
853
898
|
[ATTR_DO11Y_LINK_TEXT]: sanitizeText(link.textContent, 100),
|
|
854
|
-
[ATTR_DO11Y_LINK_CONTEXT]: getLinkContext(link),
|
|
899
|
+
[ATTR_DO11Y_LINK_CONTEXT]: getLinkContext(link, config),
|
|
855
900
|
[ATTR_DO11Y_LINK_SECTION]: sanitizeText(getNearestHeading(link), 100),
|
|
856
|
-
[ATTR_DO11Y_LINK_INDEX]:
|
|
901
|
+
[ATTR_DO11Y_LINK_INDEX]: linkIndex
|
|
857
902
|
});
|
|
858
|
-
flush();
|
|
859
903
|
}, true);
|
|
860
904
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (
|
|
865
|
-
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/core/tracking/search.ts
|
|
907
|
+
function setupSearchTracking(config, emit) {
|
|
908
|
+
if (!config.trackSearch) return;
|
|
909
|
+
document.addEventListener("click", (e) => {
|
|
910
|
+
if (e.target.closest(config.searchSelector)) emit(EVENT_SEARCH_OPENED, {});
|
|
911
|
+
}, true);
|
|
912
|
+
document.addEventListener("keydown", (e) => {
|
|
913
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
914
|
+
if (!document.querySelector(config.searchSelector)) return;
|
|
915
|
+
emit(EVENT_SEARCH_OPENED, { [ATTR_DO11Y_SEARCH_TRIGGER]: "keyboard" });
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
//#endregion
|
|
920
|
+
//#region src/core/tracking/copy.ts
|
|
921
|
+
/**
|
|
922
|
+
* Pre-compute code block indices at init time to avoid O(n) querySelectorAll
|
|
923
|
+
* calls on every copy button click. Elements are assigned a
|
|
924
|
+
* data-do11y-code-idx attribute read directly on click.
|
|
925
|
+
*/
|
|
926
|
+
function precomputeCodeBlockIndices(config) {
|
|
927
|
+
try {
|
|
928
|
+
document.querySelectorAll(config.codeBlockSelector).forEach((block, idx) => {
|
|
929
|
+
block.setAttribute("data-do11y-code-idx", String(idx + 1));
|
|
930
|
+
});
|
|
931
|
+
} catch {}
|
|
932
|
+
}
|
|
933
|
+
function setupCopyTracking(config, emit) {
|
|
934
|
+
if (!config.trackCopy) return;
|
|
935
|
+
precomputeCodeBlockIndices(config);
|
|
936
|
+
document.addEventListener("click", (e) => {
|
|
937
|
+
const copyButton = e.target.closest(config.copyButtonSelector);
|
|
938
|
+
if (copyButton) {
|
|
939
|
+
const codeBlock = copyButton.closest("[class*=\"language-\"], [language]") ?? copyButton.closest(config.codeBlockSelector) ?? copyButton.closest(".expressive-code")?.querySelector("pre") ?? copyButton.closest("div, section")?.querySelector("pre") ?? copyButton.parentElement?.querySelector("pre") ?? null;
|
|
940
|
+
const language = extractCodeLanguage((codeBlock ? codeBlock.tagName === "PRE" ? codeBlock.querySelector("code") : codeBlock.querySelector("code[class*=\"language-\"], code[language]") ?? codeBlock.querySelector("code") : null) ?? codeBlock ?? copyButton);
|
|
941
|
+
const codeIndex = parseInt(codeBlock?.getAttribute("data-do11y-code-idx") ?? "1", 10);
|
|
942
|
+
emit(EVENT_CODE_COPIED, {
|
|
943
|
+
[ATTR_DO11Y_CODE_LANGUAGE]: language,
|
|
944
|
+
[ATTR_DO11Y_CODE_SECTION]: sanitizeText(getNearestHeading(codeBlock ?? copyButton), 100),
|
|
945
|
+
[ATTR_DO11Y_CODE_INDEX]: codeIndex
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}, true);
|
|
949
|
+
}
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/core/tracking/tabs.ts
|
|
952
|
+
function setupTabSwitchTracking(config, emit) {
|
|
953
|
+
if (!config.trackTabSwitches) return;
|
|
954
|
+
document.addEventListener("click", (e) => {
|
|
955
|
+
let baseSel = "[role=\"tab\"], .tabs button, .tabs a, .tabbed-labels label";
|
|
956
|
+
const safeTabSel = validateSelector(config.tabContainerSelector);
|
|
957
|
+
if (safeTabSel) baseSel += ", " + safeTabSel + " button, " + safeTabSel + " a, " + safeTabSel + " label";
|
|
958
|
+
const tab = e.target.closest(baseSel);
|
|
959
|
+
if (!tab) return;
|
|
960
|
+
if (tab.getAttribute("aria-selected") === "true" || tab.classList.contains("active") || tab.classList.contains("is-active")) return;
|
|
961
|
+
const label = sanitizeText(tab.textContent, 50);
|
|
962
|
+
if (!label) return;
|
|
963
|
+
const section = sanitizeText(getNearestHeading(tab), 100);
|
|
964
|
+
emit(EVENT_TAB_SWITCH, {
|
|
965
|
+
[ATTR_DO11Y_TAB_LABEL]: label,
|
|
966
|
+
[ATTR_DO11Y_TAB_GROUP]: section,
|
|
967
|
+
[ATTR_DO11Y_TAB_IS_DEFAULT]: false
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region src/core/tracking/toc.ts
|
|
973
|
+
function setupTocClickTracking(config, emit) {
|
|
974
|
+
if (!config.trackTocClicks) return;
|
|
975
|
+
document.addEventListener("click", (e) => {
|
|
976
|
+
const link = e.target.closest("a");
|
|
977
|
+
if (!link) return;
|
|
978
|
+
const tocContainer = resolveTocContainer(link, config);
|
|
979
|
+
if (!tocContainer) return;
|
|
980
|
+
const href = link.getAttribute("href");
|
|
981
|
+
const hash = href ? resolveTocHash(href) : null;
|
|
982
|
+
if (!hash) return;
|
|
983
|
+
const headingText = sanitizeText(link.textContent, 100);
|
|
984
|
+
let headingLevel = null;
|
|
985
|
+
try {
|
|
986
|
+
const targetId = hash.slice(1);
|
|
987
|
+
const targetEl = document.getElementById(targetId);
|
|
988
|
+
if (targetEl && /^H[1-6]$/.test(targetEl.tagName)) headingLevel = parseInt(targetEl.tagName.charAt(1), 10);
|
|
989
|
+
} catch {}
|
|
990
|
+
const tocLinks = tocContainer.querySelectorAll("a[href*=\"#\"]");
|
|
991
|
+
let tocPosition = 1;
|
|
992
|
+
for (let i = 0; i < tocLinks.length; i++) if (tocLinks[i] === link) {
|
|
993
|
+
tocPosition = i + 1;
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
emit(EVENT_TOC_CLICK, {
|
|
997
|
+
[ATTR_DO11Y_TOC_HEADING]: headingText,
|
|
998
|
+
[ATTR_DO11Y_TOC_HEADING_LEVEL]: headingLevel,
|
|
999
|
+
[ATTR_DO11Y_TOC_POSITION]: tocPosition
|
|
1000
|
+
});
|
|
1001
|
+
}, true);
|
|
1002
|
+
}
|
|
1003
|
+
//#endregion
|
|
1004
|
+
//#region src/core/tracking/feedback.ts
|
|
1005
|
+
function setupFeedbackTracking(config, emit) {
|
|
1006
|
+
if (!config.trackFeedback) return;
|
|
1007
|
+
document.addEventListener("click", (e) => {
|
|
1008
|
+
const button = e.target.closest("button, [role=\"button\"], a");
|
|
1009
|
+
if (!button) return;
|
|
1010
|
+
if (!button.closest(validateSelector(config.feedbackSelector) ?? "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"], [class*=\"was-this\"], [data-feedback]")) return;
|
|
1011
|
+
const buttonText = (button.textContent ?? "").trim().toLowerCase();
|
|
1012
|
+
const ariaLabel = (button.getAttribute("aria-label") ?? "").toLowerCase();
|
|
1013
|
+
const titleAttr = (button.getAttribute("title") ?? "").toLowerCase();
|
|
1014
|
+
const rawDataValue = button.getAttribute("data-value") ?? button.getAttribute("data-md-value") ?? button.getAttribute("data-feedback");
|
|
1015
|
+
const dataValue = rawDataValue && /^[\w\s.,!?-]{1,50}$/.test(rawDataValue) ? rawDataValue : null;
|
|
1016
|
+
let rating = null;
|
|
1017
|
+
if (dataValue) rating = dataValue;
|
|
1018
|
+
else if (/\byes\b|👍|thumbs.?up|helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "yes";
|
|
1019
|
+
else if (/\bno\b|👎|thumbs.?down|not.?helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "no";
|
|
1020
|
+
if (!rating) return;
|
|
1021
|
+
emit(EVENT_FEEDBACK, { [ATTR_DO11Y_FEEDBACK_RATING]: rating });
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/core/tracking/expand.ts
|
|
1026
|
+
function setupExpandCollapseTracking(config, emit) {
|
|
1027
|
+
if (!config.trackExpandCollapse) return;
|
|
1028
|
+
document.addEventListener("toggle", (e) => {
|
|
1029
|
+
const details = e.target;
|
|
1030
|
+
if (details.tagName !== "DETAILS") return;
|
|
1031
|
+
const summary = details.querySelector("summary");
|
|
1032
|
+
const label = sanitizeText(summary ? summary.textContent : "", 100);
|
|
1033
|
+
emit(EVENT_EXPAND_COLLAPSE, {
|
|
1034
|
+
[ATTR_DO11Y_EXPAND_SUMMARY]: label,
|
|
1035
|
+
[ATTR_DO11Y_EXPAND_ACTION]: details.open ? "expand" : "collapse",
|
|
1036
|
+
[ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(details), 100)
|
|
1037
|
+
});
|
|
1038
|
+
}, true);
|
|
1039
|
+
document.addEventListener("click", (e) => {
|
|
1040
|
+
const trigger = e.target.closest("[aria-expanded], [class*=\"accordion\"] button, [class*=\"collapsible\"] button");
|
|
1041
|
+
if (!trigger) return;
|
|
1042
|
+
if (trigger.closest("details")) return;
|
|
1043
|
+
if (trigger.closest("nav, [role=\"navigation\"], header")) return;
|
|
1044
|
+
const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
|
|
1045
|
+
emit(EVENT_EXPAND_COLLAPSE, {
|
|
1046
|
+
[ATTR_DO11Y_EXPAND_SUMMARY]: sanitizeText(trigger.textContent, 100),
|
|
1047
|
+
[ATTR_DO11Y_EXPAND_ACTION]: wasExpanded ? "collapse" : "expand",
|
|
1048
|
+
[ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(trigger), 100)
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/standalone/transport.ts
|
|
1054
|
+
let eventQueue = [];
|
|
1055
|
+
let flushTimeout = null;
|
|
1056
|
+
const rateLimiter = createRateLimiter();
|
|
1057
|
+
let isDisabled = false;
|
|
1058
|
+
let _otelLogger = null;
|
|
1059
|
+
/** Events queued while the OTel SDK is still loading from the CDN. They are
|
|
1060
|
+
* replayed once the SDK initializes and must NEVER fall through to the
|
|
1061
|
+
* HTTP transport (which would POST them to `config.endpoint`). */
|
|
1062
|
+
let pendingOtlpEvents = [];
|
|
1063
|
+
/** Single-flight guard so concurrent events don't trigger duplicate CDN loads. */
|
|
1064
|
+
let _otelInitPromise = null;
|
|
1065
|
+
/** Set once CDN init fails so we don't retry the load on every event. */
|
|
1066
|
+
let _otelInitFailed = false;
|
|
1067
|
+
function setIsDisabled(v) {
|
|
1068
|
+
isDisabled = v;
|
|
866
1069
|
}
|
|
867
|
-
function
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1070
|
+
function getIsDisabled() {
|
|
1071
|
+
return isDisabled;
|
|
1072
|
+
}
|
|
1073
|
+
function getQueueLength() {
|
|
1074
|
+
return eventQueue.length;
|
|
1075
|
+
}
|
|
1076
|
+
function queueEvent(config, eventName, eventData) {
|
|
1077
|
+
if (isDisabled) return;
|
|
1078
|
+
if (!rateLimiter.allow(eventName, eventData, config.rateLimitMs, config.debug)) return;
|
|
1079
|
+
const session = getSession();
|
|
1080
|
+
const eventTime = /* @__PURE__ */ new Date();
|
|
1081
|
+
const event = {
|
|
1082
|
+
_time: eventTime.toISOString(),
|
|
1083
|
+
eventName,
|
|
1084
|
+
[ATTR_DO11Y_DO11Y_VERSION]: VERSION,
|
|
1085
|
+
[ATTR_SESSION_ID]: session.id,
|
|
1086
|
+
[ATTR_DO11Y_SESSION_PAGE_COUNT]: session.pageCount,
|
|
1087
|
+
...getPageInfo(),
|
|
1088
|
+
...getBrowserContext(),
|
|
1089
|
+
...eventData
|
|
1090
|
+
};
|
|
1091
|
+
if (config.debug) console.log("[Do11y] Event queued:", eventName, event);
|
|
1092
|
+
if (config.destination === "otlp") {
|
|
1093
|
+
if (_otelLogger) {
|
|
1094
|
+
emitOtlpRecord(eventName, event, eventTime);
|
|
1095
|
+
return;
|
|
876
1096
|
}
|
|
877
|
-
|
|
1097
|
+
if (_otelInitFailed) {
|
|
1098
|
+
if (config.debug) console.warn("[Do11y] OTel SDK unavailable; dropping event:", eventName);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
pendingOtlpEvents.push(event);
|
|
1102
|
+
if (pendingOtlpEvents.length > 500) {
|
|
1103
|
+
pendingOtlpEvents = pendingOtlpEvents.slice(-500);
|
|
1104
|
+
console.warn("[Do11y] OTLP pending buffer capped at 500 events — oldest events dropped");
|
|
1105
|
+
}
|
|
1106
|
+
ensureOtelSdk(config);
|
|
1107
|
+
return;
|
|
878
1108
|
}
|
|
879
|
-
|
|
1109
|
+
eventQueue.push(event);
|
|
1110
|
+
if (eventQueue.length > 500) {
|
|
1111
|
+
eventQueue = eventQueue.slice(-500);
|
|
1112
|
+
console.warn("[Do11y] Event queue capped at 500 events — oldest events dropped");
|
|
1113
|
+
}
|
|
1114
|
+
if (eventQueue.length >= config.maxBatchSize) flush(config);
|
|
1115
|
+
else scheduleFlush(config);
|
|
1116
|
+
}
|
|
1117
|
+
function scheduleFlush(config) {
|
|
1118
|
+
if (flushTimeout) return;
|
|
1119
|
+
flushTimeout = setTimeout(() => flush(config), config.flushInterval);
|
|
880
1120
|
}
|
|
881
|
-
function
|
|
882
|
-
if (typeof CSS === "undefined" || typeof CSS.escape !== "function") return 1;
|
|
1121
|
+
function validateSupabaseUrl(url) {
|
|
883
1122
|
try {
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1123
|
+
const parsed = new URL(url);
|
|
1124
|
+
if (parsed.protocol !== "https:") return false;
|
|
1125
|
+
if (!parsed.hostname.endsWith(".supabase.co")) return false;
|
|
1126
|
+
return true;
|
|
1127
|
+
} catch {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
888
1130
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
if (
|
|
896
|
-
|
|
1131
|
+
function validateEndpoint(url, debug = false) {
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = new URL(url);
|
|
1134
|
+
const host = parsed.hostname;
|
|
1135
|
+
const isPrivate = host === "localhost" || host === "127.0.0.1" || host === "::1" || /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
|
|
1136
|
+
if (debug && isPrivate && parsed.protocol === "http:") return true;
|
|
1137
|
+
if (parsed.protocol !== "https:") return false;
|
|
1138
|
+
if (isPrivate) return false;
|
|
1139
|
+
return true;
|
|
1140
|
+
} catch {
|
|
1141
|
+
return false;
|
|
897
1142
|
}
|
|
898
|
-
return null;
|
|
899
1143
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1144
|
+
function validateConfig(config) {
|
|
1145
|
+
if (config.destination === "supabase") {
|
|
1146
|
+
if (!config.supabaseUrl) {
|
|
1147
|
+
if (config.debug) console.warn("[Do11y] No Supabase URL configured");
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
if (!validateSupabaseUrl(config.supabaseUrl)) {
|
|
1151
|
+
if (config.debug) console.warn("[Do11y] Invalid Supabase URL. Must be https://<project>.supabase.co");
|
|
1152
|
+
return false;
|
|
1153
|
+
}
|
|
1154
|
+
if (!config.supabaseKey || typeof config.supabaseKey !== "string" || config.supabaseKey.length < 10) {
|
|
1155
|
+
if (config.debug) console.warn("[Do11y] Invalid or missing Supabase publishable key");
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(config.supabaseTable)) {
|
|
1159
|
+
if (config.debug) console.warn("[Do11y] Invalid table name");
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
return true;
|
|
913
1163
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
checkScrollDepth();
|
|
919
|
-
ticking = false;
|
|
920
|
-
});
|
|
921
|
-
ticking = true;
|
|
1164
|
+
if (config.destination === "http") {
|
|
1165
|
+
if (!config.endpoint) {
|
|
1166
|
+
if (config.debug) console.warn("[Do11y] No HTTP endpoint configured");
|
|
1167
|
+
return false;
|
|
922
1168
|
}
|
|
1169
|
+
if (!validateEndpoint(config.endpoint, config.debug)) {
|
|
1170
|
+
if (config.debug) console.warn("[Do11y] Invalid HTTP endpoint. Must be HTTPS and not a private address.");
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
return true;
|
|
923
1174
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
const sc = scrollContainer;
|
|
929
|
-
console.log("[do11y] Using container-based scroll tracking:", sc.className || sc.tagName);
|
|
1175
|
+
if (config.destination === "otlp") {
|
|
1176
|
+
if (!config.otelSdkEndpoint) {
|
|
1177
|
+
if (config.debug) console.warn("[Do11y] No OTLP endpoint configured");
|
|
1178
|
+
return false;
|
|
930
1179
|
}
|
|
1180
|
+
return true;
|
|
931
1181
|
}
|
|
932
|
-
|
|
1182
|
+
if (config.debug) console.warn("[Do11y] Unknown destination:", config.destination);
|
|
1183
|
+
return false;
|
|
933
1184
|
}
|
|
934
1185
|
/**
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
* falls back to the window/document.
|
|
938
|
-
*
|
|
939
|
-
* If the page fits entirely in the viewport (no scrollbar), all
|
|
940
|
-
* thresholds are marked as reached since the user can see 100% of
|
|
941
|
-
* the content without scrolling.
|
|
1186
|
+
* Dynamically import the OTel Browser SDK and set up the LoggerProvider.
|
|
1187
|
+
* Only called when destination is 'otlp'.
|
|
942
1188
|
*/
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
queueEvent(EVENT_SCROLL_DEPTH, {
|
|
962
|
-
[ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
|
|
963
|
-
[ATTR_DO11Y_SCROLL_PERCENT]: 100
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
});
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
const scrollPercent = Math.round(scrollTop / docHeight * 100);
|
|
970
|
-
config.scrollThresholds.forEach((threshold) => {
|
|
971
|
-
if (scrollPercent >= threshold && !trackedScrollDepths.has(threshold)) {
|
|
972
|
-
trackedScrollDepths.add(threshold);
|
|
973
|
-
queueEvent(EVENT_SCROLL_DEPTH, {
|
|
974
|
-
[ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
|
|
975
|
-
[ATTR_DO11Y_SCROLL_PERCENT]: scrollPercent
|
|
976
|
-
});
|
|
977
|
-
}
|
|
1189
|
+
/** CDN base URL for dynamic OTel SDK imports. Pinned at build time.
|
|
1190
|
+
* Change this constant (not a config field) to switch CDN providers. */
|
|
1191
|
+
const OTEL_CDN_BASE = "https://esm.sh/";
|
|
1192
|
+
/** Version of the OTel SDK packages loaded from the CDN.
|
|
1193
|
+
* Keep in sync with the `@opentelemetry/*` peer/dev dependencies in package.json. */
|
|
1194
|
+
const OTEL_SDK_VERSION = "0.221.0";
|
|
1195
|
+
/** Emit a single event through the OTel Logger. */
|
|
1196
|
+
function emitOtlpRecord(eventName, event, eventTime) {
|
|
1197
|
+
if (!_otelLogger) return;
|
|
1198
|
+
const otelAttributes = { ...event };
|
|
1199
|
+
delete otelAttributes._time;
|
|
1200
|
+
delete otelAttributes.eventName;
|
|
1201
|
+
_otelLogger.emit({
|
|
1202
|
+
eventName,
|
|
1203
|
+
severityNumber: 9,
|
|
1204
|
+
timestamp: eventTime.getTime(),
|
|
1205
|
+
attributes: otelAttributes,
|
|
1206
|
+
body: ""
|
|
978
1207
|
});
|
|
979
1208
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if (pageExited) return;
|
|
987
|
-
pageExited = true;
|
|
988
|
-
if (isPageVisible) totalActiveTime += Date.now() - lastActivityTime;
|
|
989
|
-
const totalTime = Date.now() - pageLoadTime;
|
|
990
|
-
const engagementRatio = totalTime > 0 ? totalActiveTime / totalTime : 0;
|
|
991
|
-
let maxScroll = 0;
|
|
992
|
-
trackedScrollDepths.forEach((depth) => {
|
|
993
|
-
if (depth > maxScroll) maxScroll = depth;
|
|
994
|
-
});
|
|
995
|
-
flushVisibleSections();
|
|
996
|
-
const session = getSession();
|
|
997
|
-
queueEvent(EVENT_PAGE_EXIT, {
|
|
998
|
-
[ATTR_DO11Y_TOTAL_TIME_SECONDS]: Math.round(totalTime / 1e3),
|
|
999
|
-
[ATTR_DO11Y_ACTIVE_TIME_SECONDS]: Math.round(totalActiveTime / 1e3),
|
|
1000
|
-
[ATTR_DO11Y_ENGAGEMENT_RATIO]: Math.round(engagementRatio * 100) / 100,
|
|
1001
|
-
[ATTR_DO11Y_MAX_SCROLL_DEPTH]: maxScroll,
|
|
1002
|
-
[ATTR_DO11Y_REFERRER_CATEGORY]: session.referrerCategory,
|
|
1003
|
-
[ATTR_DO11Y_AI_PLATFORM]: session.aiPlatform
|
|
1004
|
-
});
|
|
1209
|
+
/** Replay events buffered while the OTel SDK was still loading. */
|
|
1210
|
+
function drainPendingOtlp() {
|
|
1211
|
+
if (!_otelLogger || pendingOtlpEvents.length === 0) return;
|
|
1212
|
+
const batch = pendingOtlpEvents;
|
|
1213
|
+
pendingOtlpEvents = [];
|
|
1214
|
+
for (const evt of batch) emitOtlpRecord(evt.eventName, evt, new Date(evt._time));
|
|
1005
1215
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
window.addEventListener("beforeunload", () => {
|
|
1019
|
-
emitPageExit();
|
|
1020
|
-
cleanup();
|
|
1216
|
+
/** Kick off the async CDN SDK load exactly once. Buffered events are replayed
|
|
1217
|
+
* by initOtelSdk on success, or dropped with a warning on failure. */
|
|
1218
|
+
function ensureOtelSdk(config) {
|
|
1219
|
+
if (_otelLogger || _otelInitPromise || _otelInitFailed) return;
|
|
1220
|
+
_otelInitPromise = initOtelSdk(config).catch((err) => {
|
|
1221
|
+
_otelInitFailed = true;
|
|
1222
|
+
pendingOtlpEvents = [];
|
|
1223
|
+
console.warn("[Do11y] OTel SDK initialization failed; buffered events dropped:", err);
|
|
1224
|
+
}).finally(() => {
|
|
1225
|
+
_otelInitPromise = null;
|
|
1021
1226
|
});
|
|
1022
1227
|
}
|
|
1023
|
-
function
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1228
|
+
async function initOtelSdk(config) {
|
|
1229
|
+
if (_otelLogger) return;
|
|
1230
|
+
const cdnBase = OTEL_CDN_BASE;
|
|
1231
|
+
const importModule = async (spec) => {
|
|
1232
|
+
return import(
|
|
1233
|
+
/* @vite-ignore */
|
|
1234
|
+
spec
|
|
1235
|
+
);
|
|
1236
|
+
};
|
|
1237
|
+
const apiLogs = await importModule(`${cdnBase}@opentelemetry/api-logs@${OTEL_SDK_VERSION}`);
|
|
1238
|
+
const sdkLogs = await importModule(`${cdnBase}@opentelemetry/sdk-logs@${OTEL_SDK_VERSION}`);
|
|
1239
|
+
const otlpExporter = await importModule(`${cdnBase}@opentelemetry/exporter-logs-otlp-http@${OTEL_SDK_VERSION}`);
|
|
1240
|
+
const resourceAttrs = {
|
|
1241
|
+
"service.name": config.otelSdkServiceName || "do11y",
|
|
1242
|
+
"service.version": VERSION,
|
|
1243
|
+
"telemetry.sdk.name": "do11y",
|
|
1244
|
+
"telemetry.sdk.language": "webjs",
|
|
1245
|
+
"telemetry.sdk.version": VERSION,
|
|
1246
|
+
...config.otelSdkResourceAttributes
|
|
1247
|
+
};
|
|
1248
|
+
const loggerProvider = new sdkLogs.LoggerProvider({
|
|
1249
|
+
resource: { attributes: resourceAttrs },
|
|
1250
|
+
processors: [new sdkLogs.BatchLogRecordProcessor({ exporter: new otlpExporter.OTLPLogExporter({
|
|
1251
|
+
url: config.otelSdkEndpoint.replace(/\/$/, "") + "/v1/logs",
|
|
1252
|
+
headers: config.otelSdkHeaders
|
|
1253
|
+
}) })]
|
|
1029
1254
|
});
|
|
1255
|
+
apiLogs.logs.setGlobalLoggerProvider(loggerProvider);
|
|
1256
|
+
_otelLogger = loggerProvider.getLogger("do11y");
|
|
1257
|
+
drainPendingOtlp();
|
|
1258
|
+
if (config.debug) console.log("[Do11y] OTel SDK initialized with endpoint:", config.otelSdkEndpoint);
|
|
1030
1259
|
}
|
|
1031
|
-
function
|
|
1032
|
-
if (
|
|
1033
|
-
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
function setupSectionVisibilityTracking() {
|
|
1056
|
-
if (!config.trackSectionVisibility) return;
|
|
1057
|
-
if (typeof IntersectionObserver === "undefined") return;
|
|
1058
|
-
const threshold = config.sectionVisibleThreshold * 1e3;
|
|
1059
|
-
sectionObserver = new IntersectionObserver((entries) => {
|
|
1060
|
-
entries.forEach((entry) => {
|
|
1061
|
-
const id = entry.target.getAttribute("data-do11y-section-id");
|
|
1062
|
-
if (!id) return;
|
|
1063
|
-
if (entry.isIntersecting) {
|
|
1064
|
-
if (!sectionTimers[id]) sectionTimers[id] = {
|
|
1065
|
-
start: Date.now(),
|
|
1066
|
-
reported: false
|
|
1067
|
-
};
|
|
1068
|
-
} else {
|
|
1069
|
-
if (sectionTimers[id] && !sectionTimers[id].reported) {
|
|
1070
|
-
const elapsed = Date.now() - sectionTimers[id].start;
|
|
1071
|
-
if (elapsed >= threshold) {
|
|
1072
|
-
const heading = entry.target.textContent?.trim() ?? "";
|
|
1073
|
-
queueEvent(EVENT_SECTION_VISIBLE, {
|
|
1074
|
-
[ATTR_DO11Y_SECTION_HEADING]: sanitizeText(heading, 100),
|
|
1075
|
-
[ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(entry.target.tagName.charAt(1), 10),
|
|
1076
|
-
[ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsed / 1e3)
|
|
1077
|
-
});
|
|
1078
|
-
sectionTimers[id].reported = true;
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
delete sectionTimers[id];
|
|
1082
|
-
}
|
|
1083
|
-
});
|
|
1084
|
-
}, { threshold: .5 });
|
|
1085
|
-
observeHeadings();
|
|
1260
|
+
function buildRequest(events, config) {
|
|
1261
|
+
if (config.destination === "supabase") {
|
|
1262
|
+
const url = config.supabaseUrl.replace(/\/$/, "") + "/rest/v1/" + config.supabaseTable;
|
|
1263
|
+
const bodyTransform = config.bodyTransform ?? ((evts) => evts.map((e) => ({ payload: e })));
|
|
1264
|
+
return {
|
|
1265
|
+
url,
|
|
1266
|
+
headers: {
|
|
1267
|
+
apikey: config.supabaseKey,
|
|
1268
|
+
Authorization: "Bearer " + config.supabaseKey,
|
|
1269
|
+
"Content-Type": "application/json",
|
|
1270
|
+
Prefer: "return=minimal"
|
|
1271
|
+
},
|
|
1272
|
+
body: JSON.stringify(bodyTransform(events))
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const bodyTransform = config.bodyTransform ?? ((evts) => evts);
|
|
1276
|
+
return {
|
|
1277
|
+
url: config.endpoint,
|
|
1278
|
+
headers: {
|
|
1279
|
+
"Content-Type": "application/json",
|
|
1280
|
+
...config.headers
|
|
1281
|
+
},
|
|
1282
|
+
body: JSON.stringify(bodyTransform(events))
|
|
1283
|
+
};
|
|
1086
1284
|
}
|
|
1087
|
-
function
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
}
|
|
1285
|
+
function isCrossOrigin(url) {
|
|
1286
|
+
try {
|
|
1287
|
+
return new URL(url).origin !== window.location.origin;
|
|
1288
|
+
} catch {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1093
1291
|
}
|
|
1094
|
-
function
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
[ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(el.tagName.charAt(1), 10),
|
|
1108
|
-
[ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsed / 1e3)
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1292
|
+
function sendEvents(req, events, retriesLeft, config) {
|
|
1293
|
+
const crossOrigin = isCrossOrigin(req.url);
|
|
1294
|
+
if (config.debug && crossOrigin) console.log("[Do11y] Cross-origin request to", new URL(req.url).origin, "- requires CORS headers on the server");
|
|
1295
|
+
fetch(req.url, {
|
|
1296
|
+
method: "POST",
|
|
1297
|
+
headers: req.headers,
|
|
1298
|
+
body: req.body,
|
|
1299
|
+
keepalive: true,
|
|
1300
|
+
mode: crossOrigin ? "cors" : "same-origin"
|
|
1301
|
+
}).then((response) => {
|
|
1302
|
+
if (response.ok) {
|
|
1303
|
+
if (config.debug) console.log("[Do11y] Flushed", events.length, "events");
|
|
1304
|
+
return;
|
|
1111
1305
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
});
|
|
1306
|
+
if (retriesLeft > 0 && (response.status >= 500 || response.status === 429)) {
|
|
1307
|
+
if (config.debug) console.log("[Do11y] Retrying after error:", response.status);
|
|
1308
|
+
eventQueue = events.concat(eventQueue);
|
|
1309
|
+
setTimeout(() => flush(config, retriesLeft - 1), config.retryDelay * (config.maxRetries - retriesLeft + 1));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (config.debug) response.text().then((text) => {
|
|
1313
|
+
const msg = `[Do11y] Ingest failed: ${response.status}`;
|
|
1314
|
+
if (response.status === 0 && response.type === "opaque") console.error(msg, "- CORS error: server did not return Access-Control-Allow-Origin");
|
|
1315
|
+
else console.error(msg, text);
|
|
1316
|
+
}).catch(() => {});
|
|
1317
|
+
}).catch((err) => {
|
|
1318
|
+
if (retriesLeft > 0) {
|
|
1319
|
+
if (config.debug) {
|
|
1320
|
+
const hint = crossOrigin ? " (this may be a CORS issue — try using an OTel Collector proxy)" : "";
|
|
1321
|
+
console.log("[Do11y] Network error, retrying:", err.message + hint);
|
|
1322
|
+
}
|
|
1323
|
+
eventQueue = events.concat(eventQueue);
|
|
1324
|
+
setTimeout(() => flush(config, retriesLeft - 1), config.retryDelay * (config.maxRetries - retriesLeft + 1));
|
|
1325
|
+
} else if (config.debug) console.error("[Do11y] Failed to send events:", err.message);
|
|
1132
1326
|
});
|
|
1133
1327
|
}
|
|
1134
|
-
function
|
|
1135
|
-
if (
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
if (
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
if (!hash) return;
|
|
1144
|
-
const headingText = sanitizeText(link.textContent, 100);
|
|
1145
|
-
let headingLevel = null;
|
|
1146
|
-
try {
|
|
1147
|
-
const targetId = hash.slice(1);
|
|
1148
|
-
const targetEl = document.getElementById(targetId);
|
|
1149
|
-
if (targetEl && /^H[1-6]$/.test(targetEl.tagName)) headingLevel = parseInt(targetEl.tagName.charAt(1), 10);
|
|
1150
|
-
} catch {}
|
|
1151
|
-
const tocLinks = tocContainer.querySelectorAll("a[href*=\"#\"]");
|
|
1152
|
-
let tocPosition = 1;
|
|
1153
|
-
for (let i = 0; i < tocLinks.length; i++) if (tocLinks[i] === link) {
|
|
1154
|
-
tocPosition = i + 1;
|
|
1155
|
-
break;
|
|
1328
|
+
function flush(config, retriesLeft) {
|
|
1329
|
+
if (flushTimeout) {
|
|
1330
|
+
clearTimeout(flushTimeout);
|
|
1331
|
+
flushTimeout = null;
|
|
1332
|
+
}
|
|
1333
|
+
if (config.destination === "otlp") {
|
|
1334
|
+
if (eventQueue.length > 0) {
|
|
1335
|
+
if (config.debug) console.warn("[Do11y] Dropping " + eventQueue.length + " queued events (OTLP mode has no HTTP transport)");
|
|
1336
|
+
eventQueue = [];
|
|
1156
1337
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
if (!config.trackFeedback) return;
|
|
1166
|
-
document.addEventListener("click", (e) => {
|
|
1167
|
-
const button = e.target.closest("button, [role=\"button\"], a");
|
|
1168
|
-
if (!button) return;
|
|
1169
|
-
if (!button.closest(validateSelector(config.feedbackSelector) ?? "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"], [class*=\"was-this\"], [data-feedback]")) return;
|
|
1170
|
-
const buttonText = (button.textContent ?? "").trim().toLowerCase();
|
|
1171
|
-
const ariaLabel = (button.getAttribute("aria-label") ?? "").toLowerCase();
|
|
1172
|
-
const titleAttr = (button.getAttribute("title") ?? "").toLowerCase();
|
|
1173
|
-
const rawDataValue = button.getAttribute("data-value") ?? button.getAttribute("data-md-value") ?? button.getAttribute("data-feedback");
|
|
1174
|
-
const dataValue = rawDataValue && /^[\w\s.,!?-]{1,50}$/.test(rawDataValue) ? rawDataValue : null;
|
|
1175
|
-
let rating = null;
|
|
1176
|
-
if (dataValue) rating = dataValue;
|
|
1177
|
-
else if (/\byes\b|👍|thumbs.?up|helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "yes";
|
|
1178
|
-
else if (/\bno\b|👎|thumbs.?down|not.?helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "no";
|
|
1179
|
-
if (!rating) return;
|
|
1180
|
-
queueEvent(EVENT_FEEDBACK, { [ATTR_DO11Y_FEEDBACK_RATING]: rating });
|
|
1181
|
-
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (eventQueue.length === 0) return;
|
|
1341
|
+
if (!validateConfig(config)) return;
|
|
1342
|
+
const retries = typeof retriesLeft === "number" ? retriesLeft : config.maxRetries;
|
|
1343
|
+
const events = eventQueue.slice();
|
|
1344
|
+
eventQueue = [];
|
|
1345
|
+
sendEvents(buildRequest(events, config), events, retries, config);
|
|
1182
1346
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
queueEvent(EVENT_EXPAND_COLLAPSE, {
|
|
1203
|
-
[ATTR_DO11Y_EXPAND_SUMMARY]: sanitizeText(trigger.textContent, 100),
|
|
1204
|
-
[ATTR_DO11Y_EXPAND_ACTION]: wasExpanded ? "collapse" : "expand",
|
|
1205
|
-
[ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(trigger), 100)
|
|
1347
|
+
/**
|
|
1348
|
+
* Synchronous flush used on `beforeunload`. For OTLP mode the SDK
|
|
1349
|
+
* handles flush on its own; for HTTP/Supabase we use fetch with keepalive.
|
|
1350
|
+
* sendBeacon is not used because Supabase requires custom headers
|
|
1351
|
+
* (apikey, Authorization) which sendBeacon does not support.
|
|
1352
|
+
*/
|
|
1353
|
+
function flushSync(config) {
|
|
1354
|
+
if (config.destination === "otlp") return;
|
|
1355
|
+
if (eventQueue.length === 0) return;
|
|
1356
|
+
if (!validateConfig(config)) return;
|
|
1357
|
+
const events = eventQueue;
|
|
1358
|
+
eventQueue = [];
|
|
1359
|
+
const req = buildRequest(events, config);
|
|
1360
|
+
try {
|
|
1361
|
+
fetch(req.url, {
|
|
1362
|
+
method: "POST",
|
|
1363
|
+
headers: req.headers,
|
|
1364
|
+
body: req.body,
|
|
1365
|
+
keepalive: true
|
|
1206
1366
|
});
|
|
1207
|
-
}
|
|
1367
|
+
} catch {}
|
|
1368
|
+
if (config.debug) console.log("[Do11y] Sync flushed", events.length, "events");
|
|
1369
|
+
}
|
|
1370
|
+
function cleanup() {
|
|
1371
|
+
if (flushTimeout) {
|
|
1372
|
+
clearTimeout(flushTimeout);
|
|
1373
|
+
flushTimeout = null;
|
|
1374
|
+
}
|
|
1208
1375
|
}
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/standalone/index.ts
|
|
1378
|
+
const config = {
|
|
1379
|
+
destination: "supabase",
|
|
1380
|
+
supabaseUrl: "",
|
|
1381
|
+
supabaseKey: "",
|
|
1382
|
+
supabaseTable: "do11y_events",
|
|
1383
|
+
endpoint: "",
|
|
1384
|
+
headers: {},
|
|
1385
|
+
bodyTransform: void 0,
|
|
1386
|
+
otelSdkEndpoint: "",
|
|
1387
|
+
otelSdkHeaders: {},
|
|
1388
|
+
otelSdkServiceName: "do11y",
|
|
1389
|
+
otelSdkResourceAttributes: {},
|
|
1390
|
+
debug: false,
|
|
1391
|
+
flushInterval: 5e3,
|
|
1392
|
+
maxBatchSize: 10,
|
|
1393
|
+
trackOutboundLinks: true,
|
|
1394
|
+
trackInternalLinks: true,
|
|
1395
|
+
trackScrollDepth: true,
|
|
1396
|
+
scrollThresholds: [
|
|
1397
|
+
25,
|
|
1398
|
+
50,
|
|
1399
|
+
75,
|
|
1400
|
+
90
|
|
1401
|
+
],
|
|
1402
|
+
allowedDomains: null,
|
|
1403
|
+
respectDNT: true,
|
|
1404
|
+
maxRetries: 2,
|
|
1405
|
+
retryDelay: 1e3,
|
|
1406
|
+
rateLimitMs: 100,
|
|
1407
|
+
framework: "mintlify",
|
|
1408
|
+
trackSectionVisibility: true,
|
|
1409
|
+
sectionVisibleThreshold: 3,
|
|
1410
|
+
trackSearch: true,
|
|
1411
|
+
trackCopy: true,
|
|
1412
|
+
trackTabSwitches: true,
|
|
1413
|
+
trackTocClicks: true,
|
|
1414
|
+
trackExpandCollapse: true,
|
|
1415
|
+
trackFeedback: true,
|
|
1416
|
+
tabContainerSelector: null,
|
|
1417
|
+
tocSelector: null,
|
|
1418
|
+
feedbackSelector: null,
|
|
1419
|
+
searchSelector: null,
|
|
1420
|
+
copyButtonSelector: null,
|
|
1421
|
+
codeBlockSelector: null,
|
|
1422
|
+
navigationSelector: null,
|
|
1423
|
+
footerSelector: null,
|
|
1424
|
+
contentSelector: null,
|
|
1425
|
+
trackSpaPathChanges: false,
|
|
1426
|
+
sessionAttributes: true
|
|
1427
|
+
};
|
|
1428
|
+
const _alreadyLoaded = !!window.__do11yInitialized;
|
|
1429
|
+
window.__do11yInitialized = true;
|
|
1430
|
+
const _isInIframe = window.self !== window.top;
|
|
1431
|
+
if (_isInIframe && !_alreadyLoaded) window.__do11yInitialized = false;
|
|
1209
1432
|
let mutationObserver = null;
|
|
1433
|
+
let pathPollId = null;
|
|
1210
1434
|
function init() {
|
|
1211
|
-
|
|
1212
|
-
|
|
1435
|
+
const cfg = config;
|
|
1436
|
+
const userCfg = window.Do11yConfig;
|
|
1437
|
+
if (userCfg && typeof userCfg === "object") {
|
|
1438
|
+
for (const key in config) if (Object.prototype.hasOwnProperty.call(userCfg, key)) {
|
|
1439
|
+
const val = userCfg[key];
|
|
1440
|
+
if (val !== void 0) cfg[key] = val;
|
|
1441
|
+
}
|
|
1213
1442
|
}
|
|
1214
1443
|
const metaDestination = document.querySelector("meta[name=\"do11y-destination\"]");
|
|
1215
1444
|
if (metaDestination) {
|
|
@@ -1239,95 +1468,102 @@
|
|
|
1239
1468
|
if (domainsStr) config.allowedDomains = domainsStr.split(",").map((d) => d.trim());
|
|
1240
1469
|
}
|
|
1241
1470
|
const metaFramework = document.querySelector("meta[name=\"do11y-framework\"]");
|
|
1242
|
-
if (metaFramework)
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1471
|
+
if (metaFramework) {
|
|
1472
|
+
const rawFramework = metaFramework.getAttribute("content");
|
|
1473
|
+
if (rawFramework && [
|
|
1474
|
+
"mintlify",
|
|
1475
|
+
"docusaurus",
|
|
1476
|
+
"nextra",
|
|
1477
|
+
"mkdocs-material",
|
|
1478
|
+
"vitepress",
|
|
1479
|
+
"starlight",
|
|
1480
|
+
"docsy",
|
|
1481
|
+
"custom"
|
|
1482
|
+
].includes(rawFramework)) config.framework = rawFramework;
|
|
1483
|
+
else if (rawFramework && config.debug) console.warn("[Do11y] Unknown framework in meta tag: \"" + rawFramework + "\". Using default: " + config.framework);
|
|
1255
1484
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1485
|
+
applyFrameworkSelectors(config);
|
|
1486
|
+
if (config.debug) console.log("[Do11y] Initializing with config:", {
|
|
1487
|
+
destination: config.destination,
|
|
1488
|
+
framework: config.framework,
|
|
1489
|
+
allowedDomains: config.allowedDomains,
|
|
1490
|
+
respectDNT: config.respectDNT
|
|
1491
|
+
});
|
|
1492
|
+
if (shouldDisableTracking(config)) {
|
|
1493
|
+
setIsDisabled(true);
|
|
1258
1494
|
if (config.debug) console.log("[Do11y] Tracking disabled");
|
|
1259
1495
|
return;
|
|
1260
1496
|
}
|
|
1261
1497
|
if (!(config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint)) {
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
else console.warn("[Do11y] Add <meta name=\"do11y-endpoint\"> to enable.");
|
|
1267
|
-
}
|
|
1498
|
+
console.warn("[Do11y] No destination configured. Events will not be sent.");
|
|
1499
|
+
if (config.destination === "supabase") console.warn("[Do11y] Add <meta name=\"do11y-url\"> and <meta name=\"do11y-key\"> to enable.");
|
|
1500
|
+
else if (config.destination === "otlp") console.warn("[Do11y] Add <meta name=\"do11y-otlp-endpoint\"> to enable.");
|
|
1501
|
+
else console.warn("[Do11y] Add <meta name=\"do11y-endpoint\"> to enable.");
|
|
1268
1502
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1503
|
+
const emit = (eventName, eventData) => {
|
|
1504
|
+
queueEvent(config, eventName, eventData);
|
|
1505
|
+
};
|
|
1506
|
+
trackPageView(config, emit);
|
|
1507
|
+
setupLinkTracking(config, emit);
|
|
1508
|
+
setupScrollTracking(config, emit);
|
|
1509
|
+
setupEngagementTracking(config, emit);
|
|
1510
|
+
setupSearchTracking(config, emit);
|
|
1511
|
+
setupCopyTracking(config, emit);
|
|
1512
|
+
setupSectionVisibilityTracking(config, emit);
|
|
1513
|
+
setupTabSwitchTracking(config, emit);
|
|
1514
|
+
setupTocClickTracking(config, emit);
|
|
1515
|
+
setupFeedbackTracking(config, emit);
|
|
1516
|
+
setupExpandCollapseTracking(config, emit);
|
|
1280
1517
|
let lastPath = window.location.pathname;
|
|
1281
|
-
|
|
1282
|
-
if (window.location.pathname
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
checkScrollDepth();
|
|
1293
|
-
}
|
|
1294
|
-
});
|
|
1518
|
+
const handlePathChange = () => {
|
|
1519
|
+
if (window.location.pathname === lastPath) return;
|
|
1520
|
+
lastPath = window.location.pathname;
|
|
1521
|
+
emitPageExit(config, emit, () => flush(config));
|
|
1522
|
+
resetTrackedScrollDepths();
|
|
1523
|
+
resetEngagementState();
|
|
1524
|
+
trackPageView(config, emit);
|
|
1525
|
+
observeHeadings();
|
|
1526
|
+
checkScrollDepth(config, emit);
|
|
1527
|
+
};
|
|
1528
|
+
mutationObserver = new MutationObserver(handlePathChange);
|
|
1295
1529
|
mutationObserver.observe(document.body, {
|
|
1296
1530
|
childList: true,
|
|
1297
1531
|
subtree: true
|
|
1298
1532
|
});
|
|
1299
|
-
window.addEventListener("popstate",
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
totalActiveTime = 0;
|
|
1307
|
-
isPageVisible = true;
|
|
1308
|
-
trackPageView();
|
|
1309
|
-
observeHeadings();
|
|
1310
|
-
checkScrollDepth();
|
|
1533
|
+
window.addEventListener("popstate", handlePathChange);
|
|
1534
|
+
pathPollId = window.setInterval(handlePathChange, 200);
|
|
1535
|
+
Object.freeze(config);
|
|
1536
|
+
window.addEventListener("beforeunload", () => {
|
|
1537
|
+
if (pathPollId !== null) {
|
|
1538
|
+
clearInterval(pathPollId);
|
|
1539
|
+
pathPollId = null;
|
|
1311
1540
|
}
|
|
1541
|
+
flushSync(config);
|
|
1542
|
+
cleanup();
|
|
1543
|
+
});
|
|
1544
|
+
document.addEventListener("visibilitychange", () => {
|
|
1545
|
+
if (document.hidden && pathPollId !== null) {
|
|
1546
|
+
clearInterval(pathPollId);
|
|
1547
|
+
pathPollId = null;
|
|
1548
|
+
} else if (!document.hidden && pathPollId === null && config.trackSpaPathChanges) pathPollId = window.setInterval(handlePathChange, 200);
|
|
1312
1549
|
});
|
|
1313
|
-
Object.freeze(config);
|
|
1314
1550
|
if (config.debug) console.log("[Do11y] Initialized successfully");
|
|
1315
1551
|
}
|
|
1316
|
-
|
|
1552
|
+
/** Tear down all tracking: remove listeners, disconnect observers, flush queue. */
|
|
1553
|
+
function destroy() {
|
|
1317
1554
|
if (mutationObserver) {
|
|
1318
1555
|
mutationObserver.disconnect();
|
|
1319
1556
|
mutationObserver = null;
|
|
1320
1557
|
}
|
|
1321
|
-
if (
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
sectionObserver = null;
|
|
1325
|
-
}
|
|
1326
|
-
if (flushTimeout) {
|
|
1327
|
-
clearTimeout(flushTimeout);
|
|
1328
|
-
flushTimeout = null;
|
|
1558
|
+
if (pathPollId !== null) {
|
|
1559
|
+
clearInterval(pathPollId);
|
|
1560
|
+
pathPollId = null;
|
|
1329
1561
|
}
|
|
1330
|
-
|
|
1562
|
+
disconnectSectionObserver();
|
|
1563
|
+
flushSync(config);
|
|
1564
|
+
cleanup();
|
|
1565
|
+
setIsDisabled(true);
|
|
1566
|
+
window.__do11yInitialized = false;
|
|
1331
1567
|
}
|
|
1332
1568
|
if (!_alreadyLoaded && !_isInIframe) if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
|
1333
1569
|
else init();
|
|
@@ -1335,19 +1571,22 @@
|
|
|
1335
1571
|
getConfig: () => ({
|
|
1336
1572
|
destination: config.destination,
|
|
1337
1573
|
hasCredentials: config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint,
|
|
1338
|
-
isDisabled,
|
|
1574
|
+
isDisabled: getIsDisabled(),
|
|
1339
1575
|
allowedDomains: config.allowedDomains,
|
|
1340
1576
|
respectDNT: config.respectDNT
|
|
1341
1577
|
}),
|
|
1342
|
-
flush,
|
|
1578
|
+
flush: () => flush(config),
|
|
1343
1579
|
isEnabled: () => {
|
|
1344
|
-
if (
|
|
1580
|
+
if (getIsDisabled()) return false;
|
|
1345
1581
|
if (config.destination === "supabase") return !!config.supabaseKey;
|
|
1346
1582
|
if (config.destination === "otlp") return !!config.otelSdkEndpoint;
|
|
1347
1583
|
return !!config.endpoint;
|
|
1348
1584
|
},
|
|
1349
|
-
getQueueSize: () =>
|
|
1350
|
-
version:
|
|
1585
|
+
getQueueSize: () => getQueueLength(),
|
|
1586
|
+
version: "0.2.0",
|
|
1587
|
+
destroy: () => destroy()
|
|
1351
1588
|
};
|
|
1352
1589
|
//#endregion
|
|
1353
|
-
|
|
1590
|
+
exports.destroy = destroy;
|
|
1591
|
+
return exports;
|
|
1592
|
+
})({});
|