diff --git a/.gitignore b/.gitignore
index e63fd59..7f55a24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@ otel-agent.log
/otel-ui/node_modules/
/otel-ui/dist/
/otel-ui/dist-ssr/
+/rum/node_modules/
+/static/otel-rum.js.map
+/static/otel-todo.js.map
# Local credentials and scratch
.env
diff --git a/Dockerfile b/Dockerfile
index f8cd707..611c5cb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,6 +15,15 @@ COPY otel-ui/ .
RUN npm install && npm run build
+FROM node:24-alpine AS rum-builder
+
+WORKDIR /app/rum
+COPY rum/package*.json ./
+RUN npm ci
+COPY rum/ ./
+COPY scripts/otel-rum.test.mjs /app/scripts/otel-rum.test.mjs
+RUN npm test
+
# Go build stage
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS go-builder
@@ -46,13 +55,14 @@ WORKDIR /home/appuser/
COPY --from=go-builder /go-otel .
COPY --from=go-builder /app/otel-ui/dist ./dist
COPY static/ ./static
+COPY --from=rum-builder /app/static/ ./static/
COPY db/GeoLite2-City.mmdb db/GeoLite2-ASN.mmdb ./db/
COPY agents_example.md .
COPY instrumentation.md .
# Private server-side maps are the default. Staging/dev can opt into public
# maps for Chrome DevTools with --build-arg PUBLIC_SOURCEMAPS=true.
-RUN if [ "${PUBLIC_SOURCEMAPS}" != "true" ]; then find ./dist -type f -name '*.map' -delete; fi
+RUN if [ "${PUBLIC_SOURCEMAPS}" != "true" ]; then find ./dist ./static -type f -name '*.map' -delete; fi
# Ensure the appuser owns the files
RUN chown -R appuser:appgroup /home/appuser/
diff --git a/Makefile b/Makefile
index abebe17..3906618 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,15 @@
-.PHONY: lint install-lint
+.PHONY: build build-rum test-rum lint install-lint
+
+build: build-rum
+ go build -o server ./cmd/server
+
+build-rum:
+ npm --prefix rum ci
+ npm --prefix rum run build
+
+test-rum:
+ npm --prefix rum ci
+ npm --prefix rum test
lint:
@which golangci-lint > /dev/null || (echo "golangci-lint not found, installing..." && make install-lint)
diff --git a/docs/EU-RUM-PRIVACY.md b/docs/EU-RUM-PRIVACY.md
new file mode 100644
index 0000000..4bf60b7
--- /dev/null
+++ b/docs/EU-RUM-PRIVACY.md
@@ -0,0 +1,164 @@
+# EU browser RUM error telemetry: privacy findings
+
+Research date: 2026-11-04. Legal information, not a legal opinion or a
+deployment-specific compliance determination. SDK behaviour below is supplied
+context, not an independently audited implementation. No SDK change is proposed
+as automatically lawful.
+
+## Short answer
+
+**Not just cookies. Actual errors can sometimes be sent without consent, but
+only after two separate gates are satisfied.** Replacing every error with
+`Anonymous frontend event` is not a universal EU requirement. Conversely,
+cookie-free, identifier-free or nominally anonymous browser telemetry is not
+automatically consent-exempt. Legitimate interests cannot override an ePrivacy
+consent requirement. [1-4]
+
+## Gate 1: ePrivacy terminal storage/access
+
+Article 5(3) concerns storing information on, or accessing information already
+stored in, terminal equipment, **whether or not it is personal data**. The final
+EDPB Guidelines 2/2023 cover JavaScript-directed transmissions, locally generated
+information and RAM; there is no minimum storage duration. Thus no cookies,
+localStorage or sessionStorage is helpful but not decisive. An ephemeral
+in-memory page/session identifier can also matter. [1,3: paragraphs 4,6-12,32-39,52-56]
+
+Consent is required unless the operation falls outside that scope or an
+exception applies: storage/access solely to transmit a communication, or strictly
+necessary to provide an information-society service explicitly requested by the
+user. Assess the actual collection operation and purpose under applicable
+national transposition and regulator guidance. Not every JavaScript API is
+forbidden, and an ordinary HTTP request does not inherently require consent.
+The EDPB's IP-tracking discussion distinguishes tracking from the technical use
+of IP addresses to establish/transmit communications. [1,3: paragraph 4,
+footnote 4, paragraphs 40,54-56 and footnote 28]
+
+**Security/reliability is not a blanket exemption.** A narrowly necessary
+function protecting or delivering the requested service may qualify; general
+crash analytics, product improvement or performance measurement does not qualify
+merely because it helps the operator. WP29 gives user-centric login-abuse
+protection as a security example, but rejects a general first-party analytics
+exemption. Its examples are not approval of a RUM SDK. Country-specific analytics
+exemptions are not EU-wide permission: CNIL explicitly notes national variation.
+[4: sections 3.3,4.3; 8]
+
+## Gate 2: GDPR personal-data processing
+
+If the telemetry or its collection involves personal data, an Article 6 legal
+basis and the other GDPR requirements apply independently. IP addresses, device
+metadata, stack URLs and error messages often identify or relate to people,
+alone or in combination. Pseudonymous is not anonymous; hashing does not by
+itself anonymise data. Assess reasonably available identification/linkage means,
+including collector, proxy and vendor logs, not just the JSON payload.
+[2: Article 4, Recitals 26,30; 7]
+
+Article 6(1)(f) legitimate interests **may** support narrowly necessary service
+reliability/security diagnostics without GDPR consent. Document a
+legitimate-interest assessment before collection: a specific lawful purpose,
+necessity (including less intrusive alternatives), and balancing against people's
+rights, reasonable expectations and risks. This is a possible application of
+the test, not an official finding that error telemetry always passes. Security
+is recognised in Recital 49; it does not make all reliability analytics necessary.
+Public authorities cannot rely on Article 6(1)(f) when performing their tasks.
+[2: Article 6(1)(f), Recitals 47,49; 6]
+
+Provide Article 13 information, including purposes, basis, legitimate interests,
+recipients, retention period/criteria and rights. Apply Article 5 purpose and
+data minimisation, storage limitation and accountability. Under Article 21,
+handle objections based on the person's particular situation: cease the relevant
+processing unless the applicable overriding-ground/legal-claims exception is
+demonstrated. Special-category data needs an additional Article 9 condition;
+legitimate interests alone is insufficient. [2: Articles 5,9,13,21; 6,7,9]
+
+## If consent is denied or withdrawn
+
+Stop/do not start optional consent-dependent browser collection; do not relabel
+the **same consent-required processing** as legitimate interests after rejection.
+The EDPB requires selecting the basis in advance and rejects retrospective
+swapping from consent to legitimate interests. Independently established,
+accurately disclosed necessary diagnostics are a different question: they may
+continue only if both gates above are satisfied, not because refusal authorises
+them. Keep them separate from optional analytics/tracking. [3-6]
+
+Ordinary application error logs generated on the server while serving an actual
+request do not, merely by being logged, add browser terminal storage/access.
+GDPR can still apply. Browser RUM additionally instructs the terminal to collect
+and transmit information, so a server-logging justification cannot automatically
+be imported. Request headers, identifiers and any additional terminal access
+still need their own assessment. [1-3: application of the scope test]
+
+## Assessment of the initial SDK defaults
+
+The described no-storage/no-cookie/no-beacon defaults, `fetch` with
+`credentials: omit` and `no-referrer`, and removal of stack URLs/context reduce
+exposure. Neither transport choice nor the label "anonymous" establishes
+anonymity or exemption; the collector can still receive IP/network metadata.
+Numeric performance measurements also require the same two-gate assessment.
+Consent enabling raw diagnostics and an in-memory page ID does not remove
+minimisation, security or sensitive-data obligations. [2,3,7]
+
+**Conclusion:** blanket replacement of actual errors may be unnecessarily
+restrictive for a separately justified minimal diagnostic purpose, while the
+remaining performance collection may still need consent. Do not loosen the SDK
+based only on "no cookies" or a generic security claim.
+
+## Implemented policy controls
+
+The SDK now separates count-only errors, an explicitly configured minimal
+diagnostics sanitizer, consented diagnostics and host-allowlisted development
+diagnostics. Unknown or rejected minimal events fall back to counts, not raw
+exception content. The application is responsible for sanitizing permitted
+output and independently justifying the policy before enabling it.
+
+Development mode requires an explicit flag and an exact hostname allowlist;
+the host application must first establish its own access/acceptance flow and
+applicable legal basis. A development label, login, allowlist or SDK switch is
+not a legal exemption. Withdrawal disables development mode and drops queued
+data. Optional Web Vitals waits for permission unless independently enabled;
+already-installed library observers require a reload for complete teardown.
+
+These are engineering controls, not additional legal findings. Configuration,
+limits and examples are documented in [the instrumentation guide](../instrumentation.md#cookieless-diagnostics-and-consent).
+
+## Candidate minimal-diagnostics policy, subject to both gates
+
+These are engineering recommendations, not a statutory safe harbour:
+
+- Allow build/release, app-controlled route templates and an allowlisted safe
+ exception category. Prefer static classification over arbitrary exception text.
+- Permit only demonstrably necessary, scrubbed messages/stack frames; exclude
+ query strings, URL fragments, user content, auth tokens/headers, request or
+ response bodies, emails and user-specific paths. Scrubbing is imperfect;
+ unknown free text should be omitted rather than assumed anonymous.
+- No stable visitor ID or fingerprint; avoid even a page ID unless separately
+ necessary and assessed. Use a first-party collector without cross-site profiles
+ or vendor reuse. First-party hosting alone is not an exemption.
+- Minimise IP/device metadata at collection, including ingress/access logs.
+ Apply short, justified TTLs, deletion and access controls. There is no universal
+ numeric EU retention period for RUM; document purpose-specific periods.
+- Identify controller/processor roles, use required processor contracts
+ (Article 28), and review any third-country access/transfers (Chapter V).
+ Document the ePrivacy analysis, legitimate-interest assessment, notice and
+ objection handling before enabling the policy. [2: Articles 5,13,21,25,28,32,
+ Chapter V; 3-7,9]
+
+## Primary official sources and verification limits
+
+Only legislation and official regulator material were used. The EDPB/WP29 PDFs
+and EDPB/CNIL pages below were retrieved and read. The requested EUR-Lex links
+were checked, but returned no extractable text in this environment; their full
+texts were **not independently retrieved**. Article 5(3)'s scope/exceptions were
+cross-checked in the final EDPB guidance; GDPR findings were cross-checked against
+the EDPB guidance below. Recheck current legislation and the relevant country's
+law/guidance before an operational decision. Guidance interprets legislation;
+this note does not establish a jurisdiction-specific exemption.
+
+1. [ePrivacy Directive 2002/58/EC, consolidated 2009-12-19, Article 5(3)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02002L0058-20091219).
+2. [GDPR, Regulation (EU) 2016/679](https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng).
+3. [EDPB Guidelines 2/2023, final page](https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en) and [version 2.0 PDF](https://www.edpb.europa.eu/system/files/documents/2024-10/edpb_guidelines_202302_technical_scope_art_53_eprivacydirective_v2_en_0.pdf), adopted 7 October 2024; page published 16 October 2024. Scope guidance, not an exemption catalogue.
+4. [WP29 Opinion 04/2012 on cookie consent exemption](https://ec.europa.eu/justice/article-29/documentation/opinion-recommendation/files/2012/wp194_en.pdf), especially sections 3.3 and 4.3; historical guidance, subject to current national application.
+5. [EDPB Guidelines 05/2020 on consent, final PDF](https://www.edpb.europa.eu/system/files/documents/files/file1/edpb_guidelines_202005_consent_en.pdf), paragraphs 120-123.
+6. [EDPB: Process personal data lawfully](https://www.edpb.europa.eu/sme/be-compliant/process-personal-data-lawfully_en), legitimate interests and sensitive data.
+7. [EDPB: Data protection basics](https://www.edpb.europa.eu/sme/learn-the-basics/data-protection-basics_en), identifiability and GDPR principles.
+8. [CNIL: Sheet 16, analytics](https://www.cnil.fr/en/sheet-ndeg16-use-analytics-your-websites-and-applications), 11 June 2020; illustrative national guidance, not a current EU-wide exemption or RUM retention rule.
+9. [EDPB: Respect individuals' rights](https://www.edpb.europa.eu/sme/be-compliant/respect-individuals-rights_en), information duties and objections.
\ No newline at end of file
diff --git a/instrumentation.md b/instrumentation.md
index 8d648d8..3814546 100644
--- a/instrumentation.md
+++ b/instrumentation.md
@@ -31,6 +31,21 @@ Load the RUM script and initialize it once, before the application starts:
```
+To avoid parser-blocking downloads, use a deferred script with declarative
+initialization instead of the two scripts above:
+
+```html
+
+```
+
+Declarative initialization runs at `DOMContentLoaded`. Use the earlier,
+explicit initialization when capturing errors and requests during application
+startup matters more than avoiding parser blocking. Do not initialize twice.
+
The collector captures uncaught errors, unhandled promise rejections, failed
HTTP requests, breadcrumbs, Web Vitals, and optional trace context. Correlate
frontend requests with backend spans when possible:
@@ -52,6 +67,170 @@ try {
Do not put secrets, tokens, or sensitive customer data in breadcrumbs or
custom attributes.
+### Cookieless diagnostics and consent
+
+Neither RUM nor Todo reads or writes cookies, sessionStorage, or localStorage.
+Configuration and consent are held only in memory. Integrate with the
+application's existing consent manager rather than adding a tracking cookie
+or another banner:
+
+```js
+const rum = OtelRUM.init({ serviceName: "checkout-web" });
+rum.setConsent("granted");
+rum.setConsent("denied");
+```
+
+For a previously accepted choice, supply `consent: "granted"` at initialization
+or `data-consent="granted"` on the script. Omitted consent means `"denied"`;
+explicit configuration overrides the attribute. `rum.getConsent()` returns
+the current choice. The application is responsible for persisting that choice
+and applying it on the next page load.
+
+The SDK separates error diagnostics from optional performance collection:
+
+| Policy | Error payload | Performance collection |
+| --- | --- | --- |
+| Default `counts` | Generic event, severity, recognised error type, HTTP status, release/environment | Off without consent |
+| Configured `minimal` | Counts plus application-sanitized diagnostic fields | Still off without consent |
+| `consented` | Detailed errors, stacks, context, breadcrumbs and page metadata | On unless disabled |
+| Explicit, host-allowlisted `development` | Detailed diagnostics for an already-approved development site | On unless disabled |
+
+`rum.getPrivacyMode()` reports the configured active policy. An individual
+minimal event falls back to counts if its sanitizer does not approve it.
+Counts/minimal payloads exclude automatic URLs, user agents, viewport/connection
+details, session ids, DOM selectors, breadcrumbs, context and custom attributes.
+Per-request trace-header instrumentation still runs without persistent visitor
+identity, but counts/minimal log records do not include trace ids. Neither
+policy is a guarantee of anonymity or a consent exemption.
+
+### Minimal actual-error diagnostics
+
+Only configure this policy after separately assessing the diagnostic purpose,
+ePrivacy position and GDPR legal basis; it is not automatically enabled when
+tracking consent is rejected. Provide an application-owned sanitizer that maps
+known errors to useful, reviewed diagnostics:
+
+```js
+const safeDiagnostics = new Map([
+ ["checkout.price-missing", {
+ message: "Checkout price is missing",
+ code: "CHECKOUT_PRICE_MISSING"
+ }]
+]);
+
+const rum = OtelRUM.init({
+ serviceName: "checkout-web",
+ minimalDiagnostics: {
+ route: "/checkout/:step",
+ sanitize: event => safeDiagnostics.get(event.message) || null
+ }
+});
+
+rum.captureMessage("checkout.price-missing");
+```
+
+The sanitizer receives bounded `message`, optional `type` and `stacktrace`.
+It may return `{message, code?, stacktrace?}`; only those fields and the
+configured route template are added. Return `null` for unknown/free-text
+events. Missing, throwing or invalid sanitizers fall back to counts without
+sending the original message or stack. Output lengths are bounded, but **the
+SDK trusts sanitizer output: it does not automatically scrub returned text**.
+Only return reviewed messages and necessary scrubbed stack frames; never
+forward `event` unchanged. Exclude credentials, query strings, fragments,
+user-specific paths, user content and sensitive data. The route must be an
+application-controlled template, not `location.pathname`.
+
+On withdrawal, detailed queued records are dropped and an independently
+configured minimal policy continues. Without that policy, counts continue.
+Do not use this to relabel consent-required processing after refusal. Even
+counts require a deployment-specific assessment. See `docs/EU-RUM-PRIVACY.md`.
+
+### Development sites with existing access and acceptance
+
+Development mode is explicit and requires an **exact hostname allowlist**:
+
+```js
+const rum = OtelRUM.init({
+ serviceName: "checkout-web",
+ environment: "development",
+ devMode: accessState.loggedIn && accessState.diagnosticsAccepted,
+ devModeHosts: ["localhost", "checkout.dev.example.com"]
+});
+```
+
+Here `accessState` is provided by the host application's existing login and
+acceptance flow. Do not initialize development mode before that flow completes.
+Login alone does not establish consent or another legal basis. The SDK cannot
+verify the login, notice, acceptance or purpose; the application must do so.
+The allowlist is an accidental-production safeguard, not an authentication
+boundary. Wildcards/subdomains do not match automatically; an environment
+label never enables this mode. Ports are not part of the hostname match.
+
+For declarative setup after the access gate, use `data-dev-mode="true"` and
+`data-dev-mode-hosts="localhost,checkout.dev.example.com"`. Explicit
+`devMode: false` overrides the attribute. Manual `captureMessage(message,
+"INFO")`, exceptions, context and breadcrumbs are available for development
+improvement logs. Do not include passwords, tokens or sensitive user content.
+
+Development mode does not manufacture a consent grant: `getConsent()` still
+returns the actual configured choice. Call `rum.setConsent("denied")` on logout
+or withdrawal; it disables development mode even if consent was already denied,
+discards queued data and returns to minimal/counts. Re-enabling requires a new
+page initialization after the access gate. Todo remains consent-controlled
+separately, and is still disabled by default.
+
+### Performance permission and withdrawal
+
+Web Vitals is not loaded until consent is granted or allowed development mode
+is active. `captureWebVitals: false` disables it in all modes. If a separate
+assessment establishes permission for cookieless performance collection, set
+`captureWebVitalsWithoutConsent: true` or `data-vitals-without-consent="true"`.
+Explicit `false` overrides the attribute; this option is not a legal exemption.
+In that mode only values, deltas and numeric attribution are sent without
+consent. A random, in-memory page-view id deduplicates snapshots within one
+load; metric ids, selectors and other text attribution are omitted.
+
+Detailed diagnostics use an in-memory-only session id, renewed on reload or
+re-grant. Every consent change discards queued records, breadcrumbs, context
+and pending vitals; data already sent cannot be recalled. Reapply identifying
+context only after permission. Withdrawal suppresses later vitals callbacks
+and reports unless cookieless performance was independently enabled. Observers
+already installed inside the vendored Web Vitals library cannot be unloaded by
+this SDK; reload the page after withdrawal if complete observer teardown is
+required. If consent is revoked before its download completes, no metric
+subscriptions are started.
+
+Telemetry uses `fetch` with `credentials: "omit"`, `referrerPolicy: "no-referrer"`
+and `keepalive: true` in every mode; no cookie-bearing `sendBeacon` fallback is used. If fetch
+is unavailable or fails, telemetry is dropped. Browser-managed network headers
+and client IP addresses remain visible to the receiving infrastructure, so
+this is client-side data minimization, not a guarantee of legal anonymization
+or a determination that consent is unnecessary. Review server logging,
+retention, enrichment and applicable requirements separately.
+
+### Building the browser scripts
+
+The maintained TypeScript sources are `rum/src/otel-rum.ts` and
+`rum/src/otel-todo.ts`; public options and APIs are defined in
+`rum/src/types.ts`. Do not edit the generated `static/otel-rum.js` or
+`static/otel-todo.js` directly.
+
+Run `make build-rum` to install locked dependencies, typecheck with strict
+TypeScript, and generate separate minified IIFE bundles at the existing
+`/static/otel-rum.js` and `/static/otel-todo.js` URLs. Todo stays separate so
+it is never downloaded unless enabled. Run `make test-rum` for the same build
+plus regression tests against the generated JavaScript. After installing
+dependencies, `npm --prefix rum test` also runs the build and tests.
+
+`make build` builds the browser scripts and Go server. Docker builds and tests
+the browser scripts in an isolated Node stage automatically, so `build.sh`
+also uses fresh bundles. Generated JavaScript remains checked in for direct
+`go run ./cmd/server` development; rebuild it whenever TypeScript changes.
+
+External source maps are generated locally but are not downloaded by browsers
+automatically. Docker omits them by default; `PUBLIC_SOURCEMAPS=true` retains
+them, as for the UI. TypeScript and esbuild are build-time dependencies only.
+
## React error boundaries
Rendering errors are not reliably caught by global `window.onerror` handlers.
@@ -93,13 +272,27 @@ for individual views and the application root.
`otel-todo.js` adds a small floating symbol and a keyboard shortcut
(default `Alt+Shift+T`) that opens a corner dialog for filing a todo/issue
-from the page, with an optional component picker. The current path and hash
-are posted with it.
+from the page, with an optional component picker. User-submitted text, selected
+components and explicitly picked element references are sent as requested;
+URLs, user agents and session metadata are attached only with consent. A note
+can itself contain personal information, so it is not guaranteed anonymous.
-`otel-rum.js` loads it automatically (from the same directory as the RUM
-script) and reuses its service name, base URL and ingest key. Opt out with
-`OtelRUM.init({ excludeTodo: true })` or `data-exclude-todo="true"`. Or load it
-standalone:
+Todo is **disabled by default** in `otel-rum.js`. Opt in with
+`OtelRUM.init({ enableTodo: true })` or `data-enable-todo="true"` on the RUM
+script. It reuses the service name, base URL and ingest key, and loads from
+the same directory as the RUM script.
+
+When enabled, both loading and initialization wait until after `window.load`,
+then run during `requestIdleCallback` with no forced timeout. If idle callbacks
+are unavailable, loading waits three seconds after `load`. On a continuously
+busy page, idle loading may be delayed indefinitely. Error capture and request
+instrumentation do not wait for Todo.
+
+Explicit `enableTodo: false` overrides the opt-in attribute. The legacy
+`excludeTodo: true` and `data-exclude-todo="true"` remain supported and veto
+opt-in. Passing `excludeTodo: false` no longer enables Todo.
+
+Alternatively, load it standalone (this bypasses RUM's idle scheduling):
```html
+ *
+ * Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name
+ * (the project), data-ingest-key, data-components (comma list, also
+ * discovered from [data-component] elements on the page), data-shortcut
+ * (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true".
+ */
+import type { BrowserGlobal, Consent, TodoConfig, TodoInstance } from './types';
+
+(function (global: BrowserGlobal) {
+ 'use strict';
+ var DEFAULT_BASE_URL = 'https://otel.k6n.net';
+ var scriptEl = document.currentScript;
+
+ function attr(name: string) {
+ return (scriptEl && scriptEl.getAttribute(name)) || '';
+ }
+
+ function parseShortcut(s: string) {
+ var parts = String(s).toLowerCase().split('+');
+ var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false };
+ parts.forEach(function (p) {
+ if (p === 'alt' || p === 'option') sc.alt = true;
+ else if (p === 'shift') sc.shift = true;
+ else if (p === 'ctrl' || p === 'control') sc.ctrl = true;
+ else if (p === 'meta' || p === 'cmd') sc.meta = true;
+ });
+ return sc;
+ }
+
+ function init(config: TodoConfig = {}): TodoInstance {
+ config = config || {};
+ if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance;
+
+ var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, '');
+ var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos';
+ var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend';
+ var ingestKey = config.ingestKey || attr('data-ingest-key');
+ var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t');
+ var hideButton = config.hideButton || attr('data-hide-button') === 'true';
+ var staticComponents = config.components || attr('data-components').split(',');
+ staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean);
+
+ var consent: Consent = (config.consent || attr('data-consent')) === 'granted' ? 'granted' : 'denied';
+ function getConsent(): Consent {
+ return config.getConsent ? config.getConsent() : consent;
+ }
+ function setConsent(next: Consent) {
+ consent = next === 'granted' ? 'granted' : 'denied';
+ }
+
+ var css = document.createElement('style');
+ css.textContent =
+ '.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' +
+ 'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' +
+ '.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' +
+ 'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' +
+ 'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' +
+ 'dialog.otel-todo *{box-sizing:border-box;font:inherit}' +
+ 'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' +
+ 'dialog.otel-todo textarea{min-height:90px;resize:vertical}' +
+ 'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' +
+ 'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
+ 'dialog.otel-todo .status{margin-right:auto;font-size:12px}' +
+ '.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;' +
+ 'background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}' +
+ '.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}' +
+ '.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}' +
+ '.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;' +
+ 'border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}' +
+ '.otel-todo-ref.show{display:flex}' +
+ '.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
+ '.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}' +
+ '.otel-todo-ref button:hover{opacity:1}' +
+ '.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;' +
+ 'background:#3b82f626;border-radius:3px;transition:all .05s linear}' +
+ '.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;' +
+ 'background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;' +
+ 'box-shadow:0 4px 14px #0004}';
+ document.head.appendChild(css);
+
+ var dlg = document.createElement('dialog');
+ dlg.className = 'otel-todo';
+ dlg.setAttribute('aria-label', 'Add todo');
+ dlg.innerHTML =
+ '
';
+ var form = dlg.firstElementChild as HTMLFormElement;
+ var textEl = form.elements.namedItem('text') as HTMLTextAreaElement;
+ var selectEl = form.elements.namedItem('component') as HTMLSelectElement;
+ var statusEl = form.querySelector('.status')!;
+ var metaEl = form.querySelector('.meta')!;
+ var pickBtn = form.querySelector('.otel-todo-pick')!;
+ var refEl = form.querySelector('.otel-todo-ref')!;
+ var refLabelEl = refEl.querySelector('span')!;
+ document.body.appendChild(dlg);
+
+ // --- element picker: click a node on the page to attach its selector ---
+ var picked: { selector: string; label: string } | null = null;
+ var hoverBox: HTMLDivElement | null = null;
+ var picking = false;
+ var hint: HTMLDivElement | null = null;
+
+ function cssPath(el: Element) {
+ if (!(el instanceof Element)) return '';
+ var parts = [];
+ var node: Element | null = el;
+ while (node && node.nodeType === 1 && parts.length < 6) {
+ if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
+ var part = node.tagName.toLowerCase();
+ var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
+ if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
+ var parent: HTMLElement | null = node.parentElement;
+ if (parent) {
+ var tagName = node.tagName;
+ var siblings = Array.from(parent.children).filter(function (child) { return child.tagName === tagName; });
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
+ }
+ parts.unshift(part);
+ node = parent;
+ }
+ return parts.join(' > ');
+ }
+
+ function describeElement(el: Element) {
+ var tag = el.tagName.toLowerCase();
+ var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
+ if (text.length > 60) text = text.slice(0, 60) + '…';
+ return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag };
+ }
+
+ function setRef(ref: { selector: string; label: string } | null) {
+ picked = ref;
+ if (ref) {
+ refLabelEl.textContent = ref.label;
+ refLabelEl.title = ref.selector;
+ refEl.classList.add('show');
+ } else {
+ refEl.classList.remove('show');
+ }
+ }
+
+ function ensureHoverBox() {
+ if (!hoverBox) {
+ hoverBox = document.createElement('div');
+ hoverBox.className = 'otel-todo-hover-outline';
+ document.body.appendChild(hoverBox);
+ }
+ return hoverBox;
+ }
+
+ function onPickMove(e: MouseEvent) {
+ var el = document.elementFromPoint(e.clientX, e.clientY);
+ if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; }
+ var r = el.getBoundingClientRect();
+ var box = ensureHoverBox();
+ box.style.display = 'block';
+ box.style.left = r.left + 'px';
+ box.style.top = r.top + 'px';
+ box.style.width = r.width + 'px';
+ box.style.height = r.height + 'px';
+ }
+
+ function onPickClick(e: MouseEvent) {
+ var el = document.elementFromPoint(e.clientX, e.clientY);
+ if (!el || dlg.contains(el)) return; // let dialog controls work normally
+ e.preventDefault();
+ e.stopPropagation();
+ setRef(describeElement(el));
+ stopPicking();
+ }
+
+ function onPickKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); }
+ }
+
+ function startPicking() {
+ if (picking) return;
+ picking = true;
+ pickBtn.setAttribute('aria-pressed', 'true');
+ pickBtn.textContent = '⌖ Click an element… (Esc to cancel)';
+ hint = document.createElement('div');
+ hint.className = 'otel-todo-pick-hint';
+ hint.textContent = 'Click the element you mean — Esc to cancel';
+ document.body.appendChild(hint);
+ document.addEventListener('mousemove', onPickMove, true);
+ document.addEventListener('click', onPickClick, true);
+ document.addEventListener('keydown', onPickKey, true);
+ }
+
+ function stopPicking() {
+ if (!picking) return;
+ picking = false;
+ pickBtn.setAttribute('aria-pressed', 'false');
+ pickBtn.textContent = '⌖ Reference an element';
+ document.removeEventListener('mousemove', onPickMove, true);
+ document.removeEventListener('click', onPickClick, true);
+ document.removeEventListener('keydown', onPickKey, true);
+ if (hoverBox) { hoverBox.style.display = 'none'; }
+ if (hint) { hint.remove(); hint = null; }
+ }
+
+ pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); });
+ refEl.querySelector('[data-clear-ref]')!.addEventListener('click', function () { setRef(null); });
+
+ function components() {
+ var seen: Record = {};
+ var list: string[] = [];
+ staticComponents.concat(Array.from(document.querySelectorAll('[data-component]'), function (el) {
+ return el.getAttribute('data-component') || '';
+ })).forEach(function (c) {
+ if (c && !seen[c]) { seen[c] = true; list.push(c); }
+ });
+ return list;
+ }
+
+ function open() {
+ if (dlg.open) { textEl.focus(); return; }
+ selectEl.innerHTML = '';
+ var none = document.createElement('option');
+ none.value = '';
+ none.textContent = 'No component';
+ selectEl.appendChild(none);
+ var list = components();
+ list.forEach(function (c) {
+ var o = document.createElement('option');
+ o.value = o.textContent = c;
+ selectEl.appendChild(o);
+ });
+ selectEl.hidden = list.length === 0;
+ metaEl.textContent = project + ' · ' + location.pathname + location.hash;
+ statusEl.textContent = '';
+ setRef(null);
+ // Non-modal so the page stays usable while the note is being written.
+ dlg.show();
+ textEl.focus();
+ }
+
+ function close() {
+ stopPicking();
+ if (dlg.open) dlg.close();
+ }
+
+ form.querySelector('[data-cancel]')!.addEventListener('click', close);
+ dlg.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
+ });
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ var text = textEl.value.trim();
+ if (!text) return;
+ var headers: Record = { 'Content-Type': 'application/json' };
+ var url = endpoint;
+ if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
+ var granted = getConsent() === 'granted';
+ statusEl.textContent = 'Sending…';
+ global.fetch(url, {
+ method: 'POST',
+ mode: 'cors',
+ credentials: 'omit',
+ referrerPolicy: 'no-referrer',
+ headers: headers,
+ body: JSON.stringify({
+ project: project,
+ text: text,
+ component: selectEl.value,
+ path: granted ? location.pathname : '',
+ hash: granted ? location.hash : '',
+ url: granted ? location.href : '',
+ ua: granted ? navigator.userAgent : '',
+ session: granted && config.getSessionId ? config.getSessionId() : '',
+ elementSelector: picked ? picked.selector : '',
+ elementLabel: picked ? picked.label : ''
+ })
+ }).then(function (res) {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ textEl.value = '';
+ setRef(null);
+ close();
+ }).catch(function (err) {
+ statusEl.textContent = 'Failed: ' + err.message;
+ });
+ });
+
+ global.addEventListener('keydown', function (e) {
+ var k = e.key.toLowerCase();
+ var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase();
+ if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift &&
+ e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) {
+ e.preventDefault();
+ dlg.open ? close() : open();
+ }
+ });
+
+ if (!hideButton) {
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'otel-todo-btn';
+ btn.textContent = '✎';
+ btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')';
+ btn.setAttribute('aria-label', 'Add todo');
+ btn.addEventListener('click', function () { dlg.open ? close() : open(); });
+ document.body.appendChild(btn);
+ }
+
+ var instance = { open: open, close: close, getConsent: getConsent, setConsent: setConsent };
+ global.OtelTodo!.instance = instance;
+ return instance;
+ }
+
+ global.OtelTodo = { init: init, instance: undefined };
+
+ if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) {
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); });
+ else init();
+ }
+})(window);
diff --git a/rum/src/types.ts b/rum/src/types.ts
new file mode 100644
index 0000000..569513c
--- /dev/null
+++ b/rum/src/types.ts
@@ -0,0 +1,132 @@
+export type Consent = 'granted' | 'denied';
+
+export interface DiagnosticEvent {
+ message: string;
+ type?: string;
+ stacktrace?: string;
+}
+
+export interface MinimalDiagnostic {
+ message: string;
+ code?: string;
+ stacktrace?: string;
+}
+
+export interface MinimalDiagnosticsPolicy {
+ sanitize(event: DiagnosticEvent): MinimalDiagnostic | null;
+ route?: string;
+}
+
+export interface TodoConfig {
+ consent?: Consent;
+ getConsent?: () => Consent;
+ getSessionId?: () => string;
+ baseUrl?: string;
+ endpoint?: string;
+ serviceName?: string;
+ ingestKey?: string;
+ components?: string[];
+ shortcut?: string;
+ hideButton?: boolean;
+}
+
+export interface RumConfig {
+ consent?: Consent;
+ minimalDiagnostics?: MinimalDiagnosticsPolicy;
+ devMode?: boolean;
+ devModeHosts?: string[];
+ baseUrl?: string;
+ endpoint?: string;
+ vitalsEndpoint?: string;
+ vitalsLibUrl?: string;
+ serviceName?: string;
+ environment?: string;
+ release?: string;
+ ingestKey?: string;
+ sampleRate?: number;
+ maxBreadcrumbs?: number;
+ captureConsoleErrors?: boolean;
+ autoInstrumentFetch?: boolean;
+ autoInstrumentXhr?: boolean;
+ trackSpaRoutes?: boolean;
+ reportHttpFailures?: boolean;
+ captureWebVitals?: boolean;
+ captureWebVitalsWithoutConsent?: boolean;
+ enableTodo?: boolean;
+ excludeTodo?: boolean;
+ todoLibUrl?: string;
+ todo?: TodoConfig;
+ injectTraceHeaders?: boolean;
+ traceHeaderOrigins?: string[];
+ maxEventsPerMinute?: number;
+ batchSize?: number;
+ flushIntervalMs?: number;
+}
+
+export type Attributes = Record;
+
+export interface ExceptionLike {
+ name?: string;
+ message?: string;
+ stack?: string;
+}
+
+export interface RumInstance {
+ setConsent(consent: Consent): void;
+ getConsent(): Consent;
+ getPrivacyMode(): 'counts' | 'minimal' | 'consented' | 'development';
+ captureException(error: unknown, extra?: Attributes): void;
+ captureMessage(message: string, severity?: string, extra?: Attributes): void;
+ addBreadcrumb(category: string, message: string, extra?: { d?: number; tid?: string }): void;
+ setContext(context: Attributes): void;
+ flush(): void;
+ newTraceId(): string;
+ newSpanId(): string;
+ traceHeaders(existingTraceId?: string): { traceparent: string };
+}
+
+export interface RumAPI {
+ init(config?: RumConfig): RumInstance;
+ newTraceId(): string;
+ newSpanId(): string;
+ last?: RumInstance;
+}
+
+export interface TodoInstance {
+ setConsent(consent: Consent): void;
+ getConsent(): Consent;
+ open(): void;
+ close(): void;
+}
+
+export interface TodoAPI {
+ init(config?: TodoConfig): TodoInstance;
+ instance?: TodoInstance;
+}
+
+export interface VitalMetric {
+ name: string;
+ value: number;
+ delta: number;
+ id: string;
+ navigationType?: string;
+ attribution?: Record & {
+ longestScript?: { entry?: { sourceURL?: string; sourceFunctionName?: string } };
+ };
+}
+
+declare global {
+ interface Window {
+ OtelRUM: RumAPI;
+ OtelTodo?: TodoAPI;
+ webVitals?: Partial void) => void>>;
+ }
+ interface Navigator {
+ connection?: { effectiveType?: string };
+ }
+ interface XMLHttpRequest {
+ __rum?: { method: string; url: string; traceId?: string };
+ }
+}
+
+export type BrowserGlobal = Window & typeof globalThis;
\ No newline at end of file
diff --git a/rum/tsconfig.json b/rum/tsconfig.json
new file mode 100644
index 0000000..faf312a
--- /dev/null
+++ b/rum/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "strict": true,
+ "erasableSyntaxOnly": true,
+ "skipLibCheck": true,
+ "rootDir": "src",
+ "outDir": "../static"
+ },
+ "include": ["src/**/*.ts"]
+}
\ No newline at end of file
diff --git a/scripts/otel-rum.test.mjs b/scripts/otel-rum.test.mjs
new file mode 100644
index 0000000..5f465f4
--- /dev/null
+++ b/scripts/otel-rum.test.mjs
@@ -0,0 +1,516 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../static/otel-rum.js', import.meta.url), 'utf8');
+
+function setup({ readyState = 'loading', attributes = {}, idle = true, existingTodo = false, fetch } = {}) {
+ const scripts = [];
+ const timers = [];
+ const idleCallbacks = [];
+ const listeners = new Map();
+ const initializations = [];
+ const window = {
+ document: {
+ currentScript: {
+ src: 'https://assets.example.com/otel-rum.js',
+ getAttribute: name => attributes[name] ?? null,
+ },
+ readyState,
+ body: {},
+ createElement: tagName => ({ tagName }),
+ head: { appendChild: script => scripts.push(script) },
+ addEventListener: (...args) => window.addEventListener(...args),
+ },
+ fetch,
+ Headers,
+ URL,
+ addEventListener(name, callback, options) {
+ const callbacks = listeners.get(name) || [];
+ callbacks.push({ callback, options });
+ listeners.set(name, callbacks);
+ },
+ setTimeout(callback, delay) {
+ timers.push({ callback, delay });
+ return timers.length;
+ },
+ clearTimeout() {},
+ };
+ if (idle) window.requestIdleCallback = callback => idleCallbacks.push(callback);
+ if (existingTodo) window.OtelTodo = { init: config => initializations.push(config) };
+ vm.runInNewContext(source, { window, document: window.document });
+ return {
+ window, scripts, timers, idleCallbacks, listeners, initializations,
+ init(config = {}) {
+ return window.OtelRUM.init({ captureWebVitals: false, ...config });
+ },
+ fire(name, event) {
+ for (const listener of listeners.get(name) || []) {
+ listener.callback(event);
+ }
+ },
+ };
+}
+
+test('Todo is disabled by default, including legacy opt-out false', () => {
+ for (const config of [{}, { excludeTodo: false }, { todo: { hideButton: true } }]) {
+ const rum = setup({ readyState: 'complete' });
+ rum.init(config);
+ assert.equal(rum.scripts.length, 0);
+ assert.equal(rum.idleCallbacks.length, 0);
+ assert.equal(rum.timers.length, 0);
+ }
+});
+
+test('enabled Todo waits for window load and then idle; RUM starts immediately', () => {
+ const rum = setup();
+ const instance = rum.init({ enableTodo: true });
+ assert.equal(typeof instance.captureException, 'function');
+ assert.ok(rum.listeners.has('error'));
+ assert.equal(rum.scripts.length, 0);
+ assert.equal(rum.idleCallbacks.length, 0);
+ assert.equal(rum.listeners.get('load')[0].options.once, true);
+ rum.window.document.readyState = 'complete';
+ rum.fire('load');
+ assert.equal(rum.scripts.length, 0);
+ assert.equal(rum.idleCallbacks.length, 1);
+ rum.idleCallbacks[0]();
+ assert.equal(rum.scripts.length, 1);
+ assert.equal(rum.scripts[0].src, 'https://assets.example.com/otel-todo.js');
+ assert.equal(rum.scripts[0].async, true);
+});
+
+test('initializing after load still waits for idle and forwards Todo options', () => {
+ const rum = setup({ readyState: 'complete' });
+ rum.init({
+ enableTodo: true,
+ serviceName: 'checkout',
+ baseUrl: 'https://collector.example.com',
+ ingestKey: 'test-key',
+ todoLibUrl: '/custom-todo.js',
+ todo: { components: ['Cart'], shortcut: 'Alt+T', hideButton: true },
+ });
+ assert.equal(rum.scripts.length, 0);
+ rum.idleCallbacks[0]();
+ assert.equal(rum.scripts[0].src, '/custom-todo.js');
+ let received;
+ rum.window.OtelTodo = { init: config => { received = config; } };
+ rum.scripts[0].onload();
+ assert.equal(received.baseUrl, 'https://collector.example.com');
+ assert.equal(received.serviceName, 'checkout');
+ assert.equal(received.ingestKey, 'test-key');
+ assert.deepEqual(received.components, ['Cart']);
+ assert.equal(received.shortcut, 'Alt+T');
+ assert.equal(received.hideButton, true);
+});
+
+test('without idle callbacks Todo waits three seconds after load', () => {
+ const rum = setup({ idle: false });
+ rum.init({ enableTodo: true });
+ assert.equal(rum.timers.length, 0);
+ rum.fire('load');
+ assert.equal(rum.scripts.length, 0);
+ assert.equal(rum.timers.length, 1);
+ assert.equal(rum.timers[0].delay, 3000);
+ rum.timers[0].callback();
+ assert.equal(rum.scripts.length, 1);
+});
+
+test('declarative opt-in works and explicit false overrides it', () => {
+ for (const enabled of [true, false]) {
+ const rum = setup({ readyState: 'complete', attributes: { 'data-enable-todo': 'true' } });
+ rum.init(enabled ? {} : { enableTodo: false });
+ assert.equal(rum.idleCallbacks.length, enabled ? 1 : 0);
+ }
+});
+
+test('legacy exclusion vetoes explicit or declarative opt-in', () => {
+ for (const viaAttribute of [true, false]) {
+ const rum = setup({
+ readyState: 'complete',
+ attributes: viaAttribute ? { 'data-enable-todo': 'true', 'data-exclude-todo': 'true' } : {},
+ });
+ rum.init(viaAttribute ? {} : { enableTodo: true, excludeTodo: true });
+ assert.equal(rum.idleCallbacks.length, 0);
+ assert.equal(rum.scripts.length, 0);
+ }
+});
+
+test('preloaded Todo also waits until idle without injecting another script', () => {
+ const rum = setup({ readyState: 'complete', existingTodo: true });
+ rum.init({ enableTodo: true });
+ assert.equal(rum.initializations.length, 0);
+ rum.idleCallbacks[0]();
+ assert.equal(rum.initializations.length, 1);
+ assert.equal(rum.scripts.length, 0);
+});
+
+test('minified RUM preserves declarative initialization and currentScript configuration', () => {
+ const rum = setup({
+ attributes: {
+ 'data-service-name': 'checkout',
+ 'data-enable-todo': 'true',
+ },
+ });
+ assert.equal(rum.window.OtelRUM.last, undefined);
+ rum.window.document.readyState = 'interactive';
+ rum.fire('DOMContentLoaded');
+ assert.equal(typeof rum.window.OtelRUM.last.captureException, 'function');
+ assert.equal(rum.scripts.length, 0);
+ rum.window.OtelRUM.last.setConsent('granted');
+ assert.equal(rum.scripts[0].src, 'https://assets.example.com/web-vitals.attribution.iife.js');
+ assert.equal(rum.idleCallbacks.length, 0);
+ rum.fire('load');
+ rum.idleCallbacks[0]();
+ let received;
+ rum.window.OtelTodo = { init: config => { received = config; } };
+ rum.scripts[1].onload();
+ assert.equal(received.serviceName, 'checkout');
+});
+
+test('minified RUM sends OTLP records with configured identity and trace context', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push({ url, options });
+ return Promise.resolve({ status: 200 });
+ } });
+ const instance = rum.init({ consent: 'granted', serviceName: 'checkout', release: 'test-release', ingestKey: 'test-key' });
+ instance.setContext({ plan: 'test' });
+ const traceparent = instance.traceHeaders().traceparent;
+ assert.match(traceparent, /^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
+ instance.captureException(new Error('bundle-test'));
+ instance.flush();
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].url, 'https://otel.k6n.net/v1/logs?key=test-key');
+ const payload = JSON.parse(requests[0].options.body);
+ assert.equal(payload.resourceLogs[0].resource.attributes[0].value.stringValue, 'checkout');
+ const record = payload.resourceLogs[0].scopeLogs[0].logRecords[0];
+ assert.equal(record.body.stringValue, 'bundle-test');
+ assert.equal(record.traceId, traceparent.slice(3, 35));
+ assert.ok(record.attributes.some(attribute => attribute.key === 'service.release' && attribute.value.stringValue === 'test-release'));
+});
+
+test('minified Todo exposes the standalone API without auto-initializing', () => {
+ const todoSource = readFileSync(new URL('../static/otel-todo.js', import.meta.url), 'utf8');
+ const window = {};
+ vm.runInNewContext(todoSource, { window, document: { currentScript: null } });
+ assert.equal(typeof window.OtelTodo.init, 'function');
+ assert.equal(window.OtelTodo.instance, undefined);
+});
+
+test('anonymous RUM stays active without storage, identifying context, or credentials', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push({ url, options });
+ return Promise.resolve({ status: 200 });
+ } });
+ for (const storage of ['sessionStorage', 'localStorage']) {
+ Object.defineProperty(rum.window, storage, { get() { throw new Error('Storage must not be accessed'); } });
+ }
+ rum.window.location = { href: 'https://example.com/users/private?token=secret#email' };
+ rum.window.navigator = { userAgent: 'identifying-agent' };
+ const instance = rum.init();
+ assert.equal(instance.getConsent(), 'denied');
+ instance.setContext({ user: 'private-user' });
+ instance.addBreadcrumb('private-category', 'private-breadcrumb');
+ instance.captureException(new TypeError('private-error'), { email: 'private-email' });
+ instance.flush();
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].options.credentials, 'omit');
+ assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
+ assert.doesNotMatch(requests[0].options.body, /private|secret|identifying-agent|session.id|breadcrumbs|stacktrace/);
+ const record = JSON.parse(requests[0].options.body).resourceLogs[0].scopeLogs[0].logRecords[0];
+ assert.equal(record.body.stringValue, 'Frontend event');
+ assert.ok(record.attributes.some(attribute => attribute.key === 'exception.type' && attribute.value.stringValue === 'TypeError'));
+});
+
+test('grant and withdrawal discard queued data and require fresh identifying context', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push({ url, options });
+ return Promise.resolve({ status: 200 });
+ } });
+ const instance = rum.init();
+ instance.captureMessage('before-grant');
+ instance.setConsent('granted');
+ instance.setContext({ user: 'private-user' });
+ instance.captureMessage('accepted');
+ instance.flush();
+ assert.match(requests[0].options.body, /accepted|private-user/);
+ assert.doesNotMatch(requests[0].options.body, /before-grant/);
+ instance.captureMessage('queued-private-data');
+ instance.setConsent('denied');
+ instance.captureMessage('after-withdrawal');
+ instance.flush();
+ assert.equal(requests.length, 2);
+ assert.doesNotMatch(requests[1].options.body, /queued-private-data|private-user|session.id|after-withdrawal/);
+ assert.equal(instance.getConsent(), 'denied');
+});
+
+test('minimal policy sends only explicitly sanitized diagnostics without visitor context', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push({ url, options });
+ return Promise.resolve({ status: 200 });
+ } });
+ rum.window.location = { href: 'https://example.com/users/private?token=secret' };
+ const instance = rum.init({
+ minimalDiagnostics: {
+ route: '/users/:id',
+ sanitize(event) {
+ assert.equal(event.message, 'Cannot render private-user');
+ return { message: 'Cannot render profile', code: 'PROFILE_RENDER', stacktrace: 'at renderProfile (app.js:10:2)' };
+ },
+ },
+ });
+ instance.setContext({ user: 'private-user' });
+ instance.captureException(new TypeError('Cannot render private-user'), { token: 'secret' });
+ instance.flush();
+ assert.equal(instance.getPrivacyMode(), 'minimal');
+ assert.match(requests[0].options.body, /Cannot render profile|PROFILE_RENDER|renderProfile/);
+ assert.doesNotMatch(requests[0].options.body, /private-user|secret|session.id|browser.url|breadcrumbs/);
+ const record = JSON.parse(requests[0].options.body).resourceLogs[0].scopeLogs[0].logRecords[0];
+ assert.equal(record.body.stringValue, 'Cannot render profile');
+ assert.ok(record.attributes.some(attribute => attribute.key === 'http.route' && attribute.value.stringValue === '/users/:id'));
+});
+
+test('missing, rejecting, failing, or malformed sanitizers never expose raw errors', () => {
+ for (const sanitize of [undefined, () => null, () => { throw new Error('private'); }, () => ({ message: 123 }), () => ({ message: '' })]) {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push(options);
+ return Promise.resolve({ status: 200 });
+ } });
+ const instance = rum.init({ minimalDiagnostics: { sanitize } });
+ instance.captureMessage('private-error');
+ instance.flush();
+ assert.doesNotMatch(requests[0].body, /private-error/);
+ assert.equal(JSON.parse(requests[0].body).resourceLogs[0].scopeLogs[0].logRecords[0].body.stringValue, 'Frontend event');
+ }
+});
+
+test('development mode requires an explicit exact hostname allowlist and is revocable', () => {
+ for (const allowedHosts of [[], ['example.com'], ['dev.example.com.evil'], ['dev.example.com']]) {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push(options);
+ return Promise.resolve({ status: 200 });
+ } });
+ rum.window.location = { href: 'https://dev.example.com/private?token=secret' };
+ const instance = rum.init({ devMode: true, devModeHosts: allowedHosts });
+ const allowed = allowedHosts.includes('dev.example.com');
+ assert.equal(instance.getPrivacyMode(), allowed ? 'development' : 'counts');
+ assert.equal(instance.getConsent(), 'denied');
+ instance.setContext({ user: 'private-user' });
+ instance.captureMessage('actual-dev-error');
+ instance.flush();
+ assert.equal(requests[0].body.includes('actual-dev-error'), allowed);
+ assert.equal(requests[0].body.includes('private-user'), allowed);
+ assert.equal(requests[0].credentials, 'omit');
+ instance.captureMessage('queued-private-error');
+ instance.setConsent('denied');
+ instance.captureMessage('after-revocation');
+ instance.flush();
+ assert.equal(instance.getPrivacyMode(), 'counts');
+ assert.doesNotMatch(requests[1].body, /queued-private-error|after-revocation|private-user|secret/);
+ }
+});
+
+test('anonymous vitals retain numeric performance data without identifying metadata', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push({ url, options });
+ return Promise.resolve({ status: 200 });
+ } });
+ rum.window.location = { href: 'https://example.com/private?email=private' };
+ rum.window.navigator = { userAgent: 'private-agent', sendBeacon() { assert.fail('Beacon can send cookies'); } };
+ let onMetric;
+ rum.window.webVitals = { onLCP: callback => { onMetric = callback; } };
+ const instance = rum.init({ captureWebVitals: true, captureWebVitalsWithoutConsent: true });
+ rum.scripts[0].onload();
+ const metric = {
+ name: 'LCP', value: 1234, delta: 1234, id: 'private-metric-id',
+ attribution: { target: '#private-user', url: '/private', timeToFirstByte: 123 },
+ };
+ onMetric(metric);
+ rum.fire('pagehide');
+ const anonymous = JSON.parse(requests[0].options.body);
+ assert.equal(anonymous.metrics.LCP.value, 1234);
+ assert.equal(anonymous.metrics.LCP.attribution.timeToFirstByte, 123);
+ assert.equal(anonymous.session, '');
+ assert.match(anonymous.pageView, /^[a-f0-9]{16}$/);
+ assert.doesNotMatch(requests[0].options.body, /private/);
+ assert.equal(requests[0].options.credentials, 'omit');
+ assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
+ instance.setConsent('granted');
+ rum.fire('pagehide');
+ assert.equal(requests.length, 1);
+ onMetric(metric);
+ rum.fire('pagehide');
+ assert.match(requests[1].options.body, /private/);
+ assert.notEqual(JSON.parse(requests[1].options.body).pageView, anonymous.pageView);
+ onMetric(metric);
+ instance.setConsent('denied');
+ rum.fire('pagehide');
+ assert.equal(requests.length, 2);
+ onMetric(metric);
+ rum.fire('pagehide');
+ assert.doesNotMatch(requests[2].options.body, /private/);
+});
+
+test('declarative consent is opt-in and explicit denial takes precedence', () => {
+ for (const granted of [true, false]) {
+ const rum = setup({ attributes: { 'data-consent': 'granted' } });
+ const instance = rum.init(granted ? {} : { consent: 'denied' });
+ assert.equal(instance.getConsent(), granted ? 'granted' : 'denied');
+ }
+});
+
+test('Web Vitals loads only after permission and stops reporting on withdrawal', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push(options);
+ return Promise.resolve({ status: 200 });
+ } });
+ let onMetric;
+ rum.window.webVitals = { onLCP: callback => { onMetric = callback; } };
+ const instance = rum.init({ captureWebVitals: true });
+ assert.equal(rum.scripts.length, 0);
+ instance.setConsent('granted');
+ assert.equal(rum.scripts.length, 1);
+ rum.scripts[0].onload();
+ onMetric({ name: 'LCP', value: 100, delta: 100, id: 'test' });
+ rum.fire('pagehide');
+ assert.equal(requests.length, 1);
+ instance.setConsent('denied');
+ onMetric({ name: 'LCP', value: 200, delta: 100, id: 'test' });
+ rum.fire('pagehide');
+ assert.equal(requests.length, 1);
+ instance.setConsent('granted');
+ assert.equal(rum.scripts.length, 1);
+ onMetric({ name: 'LCP', value: 300, delta: 100, id: 'test' });
+ rum.fire('pagehide');
+ assert.equal(requests.length, 2);
+});
+
+test('permission revoked during the vitals download does not start metric subscriptions', () => {
+ const rum = setup();
+ let subscribed = false;
+ rum.window.webVitals = { onLCP() { subscribed = true; } };
+ const instance = rum.init({ consent: 'granted', captureWebVitals: true });
+ instance.setConsent('denied');
+ rum.scripts[0].onload();
+ assert.equal(subscribed, false);
+ instance.setConsent('granted');
+ assert.equal(rum.scripts.length, 2);
+ rum.scripts[1].onload();
+ assert.equal(subscribed, true);
+});
+
+test('explicit false overrides development and independently permitted vitals attributes', () => {
+ const rum = setup({ attributes: {
+ 'data-dev-mode': 'true',
+ 'data-dev-mode-hosts': 'dev.example.com',
+ 'data-vitals-without-consent': 'true',
+ } });
+ rum.window.location = { href: 'https://dev.example.com/' };
+ const instance = rum.init({ devMode: false, captureWebVitals: true, captureWebVitalsWithoutConsent: false });
+ assert.equal(instance.getPrivacyMode(), 'counts');
+ assert.equal(rum.scripts.length, 0);
+});
+
+test('development mode works declaratively without fabricating a consent grant', () => {
+ const rum = setup({ attributes: { 'data-dev-mode': 'true', 'data-dev-mode-hosts': 'localhost, dev.example.com' } });
+ rum.window.location = { href: 'https://dev.example.com/' };
+ const instance = rum.init({ captureWebVitals: true });
+ assert.equal(instance.getPrivacyMode(), 'development');
+ assert.equal(instance.getConsent(), 'denied');
+ assert.equal(rum.scripts.length, 1);
+ instance.setConsent('denied');
+ assert.equal(instance.getPrivacyMode(), 'counts');
+});
+
+test('withdrawal restores the independently configured minimal diagnostics policy', () => {
+ const requests = [];
+ const rum = setup({ fetch: (url, options) => {
+ requests.push(options);
+ return Promise.resolve({ status: 200 });
+ } });
+ const instance = rum.init({
+ consent: 'granted',
+ minimalDiagnostics: { sanitize: () => ({ message: 'Safe diagnostic' }) },
+ });
+ instance.captureMessage('queued-private');
+ instance.setConsent('denied');
+ instance.captureMessage('private-content');
+ instance.flush();
+ assert.equal(instance.getPrivacyMode(), 'minimal');
+ assert.match(requests[0].body, /Safe diagnostic/);
+ assert.doesNotMatch(requests[0].body, /private/);
+});
+
+test('credential-free telemetry never falls back to a cookie-bearing beacon', async () => {
+ for (const consent of ['denied', 'granted']) {
+ let beacons = 0;
+ const rum = setup({ fetch: () => Promise.reject(new Error('offline')) });
+ rum.window.navigator = { sendBeacon() { beacons++; } };
+ const instance = rum.init({ consent });
+ instance.captureMessage('test');
+ instance.flush();
+ await Promise.resolve();
+ assert.equal(beacons, 0);
+ }
+});
+
+test('Todo stays usable without storage and reads current consent for every submission', () => {
+ const requests = [];
+ const handlers = new Map();
+ const element = () => ({
+ addEventListener() {},
+ setAttribute() {},
+ classList: { remove() {} },
+ });
+ const text = { value: 'A deliberately submitted note' };
+ const ref = { ...element(), querySelector: () => element() };
+ const form = {
+ elements: { namedItem: name => name === 'text' ? text : { value: 'Cart' } },
+ querySelector: selector => selector === '.otel-todo-ref' ? ref : element(),
+ addEventListener: (name, handler) => handlers.set(name, handler),
+ };
+ const dialog = { ...element(), firstElementChild: form };
+ const document = {
+ currentScript: null,
+ createElement: tag => tag === 'dialog' ? dialog : element(),
+ head: { appendChild() {} },
+ body: { appendChild() {} },
+ };
+ const window = {
+ addEventListener() {},
+ fetch(url, options) {
+ requests.push({ url, options });
+ return Promise.resolve({ ok: true });
+ },
+ };
+ for (const storage of ['sessionStorage', 'localStorage']) {
+ Object.defineProperty(window, storage, { get() { assert.fail('Todo must not access storage'); } });
+ }
+ vm.runInNewContext(readFileSync(new URL('../static/otel-todo.js', import.meta.url), 'utf8'), {
+ window, document,
+ location: { pathname: '/private-user', hash: '#private', href: 'https://example.com/private' },
+ navigator: { userAgent: 'private-agent' },
+ });
+ const instance = window.OtelTodo.init({ hideButton: true });
+ const submit = () => handlers.get('submit')({ preventDefault() {} });
+ submit();
+ assert.equal(JSON.parse(requests[0].options.body).text, text.value);
+ assert.doesNotMatch(requests[0].options.body, /private/);
+ assert.equal(requests[0].options.credentials, 'omit');
+ assert.equal(requests[0].options.referrerPolicy, 'no-referrer');
+ instance.setConsent('granted');
+ submit();
+ assert.match(requests[1].options.body, /private/);
+ instance.setConsent('denied');
+ submit();
+ assert.doesNotMatch(requests[2].options.body, /private/);
+});
\ No newline at end of file
diff --git a/static/otel-rum.js b/static/otel-rum.js
index e88132d..32934b3 100644
--- a/static/otel-rum.js
+++ b/static/otel-rum.js
@@ -1,745 +1 @@
-/*!
- * otel-rum.js - browser error/RUM collector for go-otel.
- *
- * Captures uncaught errors, unhandled promise rejections, failed resource
- * loads, failing HTTP requests and Web Vitals (LCP/INP/CLS/TTFB/FCP), and
- * ships errors as OTLP log records to this server's existing /v1/logs endpoint
- * (no separate ingestion path needed - the backend's issue view picks these
- * up like any other error log). Web Vitals go out separately: one beacon per
- * page view to /v1/vitals, measured with the vendored web-vitals library.
- *
- * Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can
- * carry a W3C `traceparent` header. If the backend service is itself
- * OTel-instrumented and honors incoming trace context (most server
- * instrumentation does by default), spans it creates for that request will
- * share the same trace id - so a frontend error and the backend spans it
- * triggered can be found together via GET /api/traces/{traceId}.
- *
- * Usage:
- *
- *
- *
- * Events are batched client-side (flushed on pagehide via keepalive fetch/sendBeacon) and
- * rate-limited, so a runaway error loop cannot hammer the collector.
- *
- * Source-map resolution is performed server-side by go-otel when a matching
- * release map has been uploaded. Session replay and PII scrubbing remain out
- * of scope; don't route sensitive data through breadcrumbs/attributes.
- */
-(function (global) {
- 'use strict';
-
- var DEFAULT_BASE_URL = 'https://otel.k6n.net';
-
- // Find the loading script so declarative data-* configuration keeps working.
- try {
- var scriptEl = (typeof document !== 'undefined') ? (document.currentScript || (function() {
- var scripts = document.getElementsByTagName('script');
- for (var i = scripts.length - 1; i >= 0; i--) {
- if (scripts[i].src && scripts[i].src.indexOf('otel-rum') !== -1) return scripts[i];
- }
- return null;
- })()) : null;
- } catch (e) {}
-
- var rawFetch = typeof global.fetch === 'function' ? global.fetch : null;
- var rawXHR = typeof global.XMLHttpRequest === 'function' ? global.XMLHttpRequest : null;
-
- function randomHex(len) {
- var chars = '0123456789abcdef';
- var out = '';
- for (var i = 0; i < len; i++) {
- out += chars[(Math.random() * 16) | 0];
- }
- return out;
- }
-
- // W3C Trace Context ids: 16-byte trace id, 8-byte span id, both hex.
- function newTraceId() {
- return randomHex(32);
- }
-
- function newSpanId() {
- return randomHex(16);
- }
-
- function isSameOrigin(url) {
- try {
- var resolved = new global.URL(url, global.location.href);
- return resolved.origin === global.location.origin;
- } catch (e) {
- return false;
- }
- }
-
- function toOtlpAttributes(obj) {
- var out = [];
- for (var key in obj) {
- if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
- var value = obj[key];
- if (value === undefined || value === null || value === '') continue;
- out.push({ key: key, value: { stringValue: String(value) } });
- }
- return out;
- }
-
- function init(config) {
- config = config || {};
-
- var scriptServiceName = (scriptEl && (scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) || '';
- var scriptBaseUrl = (scriptEl && scriptEl.getAttribute('data-base-url')) || '';
- var scriptEndpoint = (scriptEl && scriptEl.getAttribute('data-endpoint')) || '';
- var scriptIngestKey = (scriptEl && scriptEl.getAttribute('data-ingest-key')) || '';
-
- // Collector base URL resolution:
- // 1. Explicit config.baseUrl or scriptEl data-base-url
- // 2. Production collector: https://otel.k6n.net
- // Do not derive this from the page or script origin: the script may be
- // bundled or self-hosted while logs still belong at the collector.
- var baseUrl = (config.baseUrl || scriptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
- var endpoint = config.endpoint || scriptEndpoint;
- if (!endpoint) {
- endpoint = baseUrl + '/v1/logs';
- } else if (!/^https?:\/\//i.test(endpoint) && !endpoint.startsWith('//')) {
- endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, '');
- }
-
- var vitalsEndpoint = config.vitalsEndpoint || (scriptEl && scriptEl.getAttribute('data-vitals-endpoint')) || baseUrl + '/v1/vitals';
- // The web-vitals build lives next to this script; fall back to the collector's /static.
- var vitalsLibUrl =
- config.vitalsLibUrl ||
- (scriptEl && scriptEl.getAttribute('data-vitals-lib')) ||
- (scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'web-vitals.attribution.iife.js' : baseUrl + '/static/web-vitals.attribution.iife.js');
-
- function isCollectorUrl(u) {
- if (!u) return false;
- var s = String(u);
- if (endpoint && s.indexOf(endpoint) === 0) return true;
- if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1 || s.indexOf('/v1/vitals') !== -1) return true;
- return false;
- }
-
- var serviceName = config.serviceName || scriptServiceName || 'frontend';
- var environment = config.environment || (scriptEl && scriptEl.getAttribute('data-environment')) || '';
- var release = config.release || (scriptEl && scriptEl.getAttribute('data-release')) || '';
- var ingestKey = config.ingestKey || scriptIngestKey || '';
- var sampleRate = typeof config.sampleRate === 'number' ? config.sampleRate : 1;
- var maxBreadcrumbs = config.maxBreadcrumbs || 20;
- var captureConsoleErrors = config.captureConsoleErrors !== false;
- var autoInstrumentFetch = config.autoInstrumentFetch !== false;
- var autoInstrumentXhr = config.autoInstrumentXhr !== false;
- var trackSpaRoutes = config.trackSpaRoutes !== false;
- var reportHttpFailures = config.reportHttpFailures !== false;
- var captureWebVitals = config.captureWebVitals !== false;
- // The todo widget (otel-todo.js) is loaded automatically unless excluded:
- // init({ excludeTodo: true }) or data-exclude-todo="true".
- var excludeTodo = config.excludeTodo === true || (scriptEl && scriptEl.getAttribute('data-exclude-todo') === 'true');
- var todoLibUrl =
- config.todoLibUrl ||
- (scriptEl && scriptEl.getAttribute('data-todo-lib')) ||
- (scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'otel-todo.js' : baseUrl + '/static/otel-todo.js');
- var injectTraceHeaders = config.injectTraceHeaders !== false;
- var traceHeaderOrigins = config.traceHeaderOrigins; // optional array; defaults to same-origin only
- // Client-side rate limiting: max events per rolling minute.
- var maxEventsPerMinute = config.maxEventsPerMinute || 60;
- // Batch size / flush interval for queued records.
- var batchSize = config.batchSize || 10;
- var flushIntervalMs = config.flushIntervalMs || 5000;
-
- var breadcrumbs = [];
- // Best-effort correlation: the most recently generated trace id is
- // attached to the next error report, so an error surfacing shortly
- // after a failed request can usually be found alongside it. This is
- // not a real per-async-context propagation mechanism.
- var lastTraceId = '';
-
- // Session id: survives reloads within the tab's lifetime, so server
- //-side issue views can group frontend errors by browsing session.
- var sessionId = '';
- try {
- sessionId = global.sessionStorage.getItem('otel-rum-session');
- if (!sessionId) {
- sessionId = randomHex(16);
- global.sessionStorage.setItem('otel-rum-session', sessionId);
- }
- } catch (e) {
- sessionId = randomHex(16); // storage blocked (privacy mode etc.)
- }
-
- // --- Client-side queue, batching and rate limiting -----------------
-
- var queue = [];
- var flushTimer = null;
- var sentTimestamps = [];
- var eventsDropped = 0;
-
- function underRateLimit() {
- var now = Date.now();
- // Drop timestamps older than the rolling 60s window.
- while (sentTimestamps.length && now - sentTimestamps[0] > 60000) {
- sentTimestamps.shift();
- }
- if (sentTimestamps.length >= maxEventsPerMinute) return false;
- sentTimestamps.push(now);
- return true;
- }
-
- function enqueue(record) {
- queue.push(record);
- if (queue.length >= batchSize) {
- flush();
- return;
- }
- if (!flushTimer) {
- flushTimer = global.setTimeout(flush, flushIntervalMs);
- }
- }
-
- function flush() {
- if (flushTimer) {
- global.clearTimeout(flushTimer);
- flushTimer = null;
- }
- if (!queue.length) return;
- var records = queue.splice(0, queue.length);
- ship(records);
- }
-
- // ship sends already-built log records in one OTLP payload. Uses
- // sendBeacon when available (survives page unload), else keepalive
- // fetch.
- function ship(records) {
- var payload = {
- resourceLogs: [
- {
- resource: { attributes: toOtlpAttributes({ 'service.name': serviceName }) },
- scopeLogs: [{ scope: { name: 'otel-rum' }, logRecords: records }]
- }
- ]
- };
-
- var body = JSON.stringify(payload);
- var url = endpoint;
- if (ingestKey) {
- url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
- }
-
- // Use uninstrumented rawFetch with keepalive: true and mode: 'cors'
- // to avoid intercepting and looping on our own telemetry requests.
- var fetchFn = rawFetch || (typeof global.fetch === 'function' ? global.fetch : null);
- if (fetchFn) {
- var fetchHeaders = { 'Content-Type': 'application/json' };
- if (ingestKey) {
- fetchHeaders['X-Otel-Ingest-Key'] = ingestKey;
- }
- fetchFn.call(global, url, {
- method: 'POST',
- mode: 'cors',
- headers: fetchHeaders,
- body: body,
- keepalive: true
- }).catch(function () {
- if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
- try {
- var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' });
- global.navigator.sendBeacon(url, blob);
- } catch (e) {}
- }
- });
- return;
- }
-
- if (global.navigator && typeof global.navigator.sendBeacon === 'function') {
- try {
- var blob = new global.Blob([body], { type: 'text/plain;charset=UTF-8' });
- global.navigator.sendBeacon(url, blob);
- } catch (e) {}
- }
- }
-
- var severityNumbers = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 };
-
- function buildRecord(severity, message, attrs, traceId) {
- var record = {
- timeUnixNano: String(Date.now() * 1e6),
- severityText: severity,
- severityNumber: severityNumbers[severity] || 17,
- body: { stringValue: String(message).slice(0, 2000) },
- attributes: toOtlpAttributes(
- Object.assign(
- {},
- contextAttrs,
- {
- source: 'frontend',
- 'browser.url': global.location ? global.location.href : '',
- 'browser.userAgent': global.navigator ? global.navigator.userAgent : '',
- 'browser.viewport': global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
- 'session.id': sessionId,
- 'service.release': release,
- 'service.environment': environment,
- breadcrumbs: breadcrumbs.length ? JSON.stringify(breadcrumbs) : undefined
- },
- attrs || {}
- )
- )
- };
- if (traceId) {
- record.traceId = traceId;
- }
- return record;
- }
-
- function send(severity, message, attrs, traceId) {
- if (sampleRate < 1 && Math.random() > sampleRate) return;
- if (!underRateLimit()) {
- eventsDropped++;
- return;
- }
- enqueue(buildRecord(severity, message, attrs, traceId));
- }
-
- // Web Vitals: measured by the vendored `web-vitals` library (attribution
- // build, loaded lazily so it never blocks the page) and reported as ONE
- // beacon per page view to /v1/vitals when the page is hidden. The
- // beacon carries every metric with its attribution (LCP element, INP
- // target/breakdown, CLS shift source...). If the page is hidden again
- // later (tab switch) and a metric changed - typically INP - a fuller
- // snapshot with the same pageView id is sent; the newest one wins.
- function observeVitals() {
- var pageViewId = randomHex(16);
- var vitals = {};
- var navType = '';
- var dirty = false;
- var sampled = sampleRate >= 1 || Math.random() <= sampleRate;
-
- var picks = {
- LCP: ['target', 'url', 'timeToFirstByte', 'resourceLoadDelay', 'resourceLoadDuration', 'elementRenderDelay'],
- INP: ['interactionTarget', 'interactionType', 'inputDelay', 'processingDuration', 'presentationDelay', 'loadState', 'totalScriptDuration', 'totalStyleAndLayoutDuration', 'totalPaintDuration'],
- CLS: ['largestShiftTarget', 'largestShiftValue', 'largestShiftTime', 'loadState'],
- FCP: ['timeToFirstByte', 'firstByteToFCP', 'loadState'],
- TTFB: ['waitingDuration', 'cacheDuration', 'dnsDuration', 'connectionDuration', 'requestDuration']
- };
-
- // Values keep 3 decimals: CLS is a tiny fraction and must not be rounded to an integer.
- function round(n) {
- return typeof n === 'number' ? Math.round(n * 1000) / 1000 : n;
- }
-
- function slim(metric) {
- var out = {};
- var src = metric.attribution || {};
- (picks[metric.name] || []).forEach(function (k) {
- if (src[k] !== undefined && src[k] !== null) out[k] = round(src[k]);
- });
- var script = src.longestScript && src.longestScript.entry;
- if (script && script.sourceURL) {
- out.longestScriptUrl = script.sourceURL;
- if (script.sourceFunctionName) out.longestScriptFunction = script.sourceFunctionName;
- }
- return out;
- }
-
- function onMetric(metric) {
- vitals[metric.name] = { value: round(metric.value), delta: round(metric.delta), id: metric.id, attribution: slim(metric) };
- navType = metric.navigationType || navType;
- dirty = true;
- }
-
- function sendVitals() {
- if (!dirty || !sampled) return;
- dirty = false;
- var body = JSON.stringify({
- service: serviceName,
- environment: environment,
- release: release,
- session: sessionId,
- pageView: pageViewId,
- url: global.location ? global.location.href : '',
- ua: global.navigator ? global.navigator.userAgent : '',
- viewport: global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
- nav: navType,
- conn: (global.navigator && global.navigator.connection && global.navigator.connection.effectiveType) || '',
- metrics: vitals
- });
- var url = vitalsEndpoint;
- if (ingestKey) url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
- // text/plain keeps it a CORS "simple" request: no preflight, so it survives unload.
- var queued = false;
- try {
- queued =
- !!(global.navigator && typeof global.navigator.sendBeacon === 'function') &&
- global.navigator.sendBeacon(url, new global.Blob([body], { type: 'text/plain;charset=UTF-8' }));
- } catch (e) {}
- if (!queued && rawFetch) {
- rawFetch.call(global, url, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body, keepalive: true }).catch(function () {});
- }
- }
-
- var script = global.document.createElement('script');
- script.async = true;
- script.src = vitalsLibUrl;
- script.onload = function () {
- var wv = global.webVitals;
- if (!wv) return;
- // INP durationThreshold stays at the library default; one listener per metric.
- [wv.onLCP, wv.onINP, wv.onCLS, wv.onFCP, wv.onTTFB].forEach(function (observe) {
- if (typeof observe === 'function') observe(onMetric);
- });
- // Registered after the library's own hidden listeners, so the
- // final LCP/INP/CLS values are already collected when this runs.
- global.addEventListener('visibilitychange', function () {
- if (global.document.visibilityState === 'hidden') sendVitals();
- });
- global.addEventListener('pagehide', sendVitals);
- // bfcache restore is a new page view for vitals purposes.
- global.addEventListener('pageshow', function (e) {
- if (e.persisted) {
- pageViewId = randomHex(16);
- vitals = {};
- }
- });
- };
- global.document.head.appendChild(script);
- }
-
- function loadTodoWidget() {
- var todo = config.todo || {};
- function start() {
- if (!global.OtelTodo) return;
- global.OtelTodo.init({
- baseUrl: baseUrl,
- serviceName: serviceName,
- ingestKey: ingestKey,
- components: todo.components || ((scriptEl && scriptEl.getAttribute('data-components')) || '').split(','),
- shortcut: todo.shortcut || (scriptEl && scriptEl.getAttribute('data-shortcut')) || '',
- hideButton: todo.hideButton === true || (scriptEl && scriptEl.getAttribute('data-hide-button') === 'true')
- });
- }
- if (global.OtelTodo) return start();
- var s = global.document.createElement('script');
- s.async = true;
- s.src = todoLibUrl;
- s.onload = start;
- global.document.head.appendChild(s);
- }
- if (!excludeTodo && global.document) {
- if (global.document.body) loadTodoWidget();
- else global.document.addEventListener('DOMContentLoaded', loadTodoWidget);
- }
-
- // traceHeaders returns a headers object carrying a fresh (or
- // continued) W3C traceparent, ready to spread into any HTTP client's
- // request headers - fetch, XHR, axios, etc.
- function traceHeaders(existingTraceId) {
- var traceId = existingTraceId || newTraceId();
- var spanId = newSpanId();
- lastTraceId = traceId;
- return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
- }
-
- function captureException(err, extra) {
- var message = (err && err.message) || String(err);
- send(
- 'ERROR',
- message,
- Object.assign(
- {
- 'exception.type': (err && err.name) || 'Error',
- 'exception.message': message,
- 'exception.stacktrace': (err && err.stack) || ''
- },
- extra || {}
- ),
- lastTraceId
- );
- }
-
- function captureMessage(message, severity, extra) {
- send(severity || 'ERROR', message, extra, lastTraceId);
- }
-
- function setContext(context) {
- // Attach stable context (e.g. { user: 'u-123', plan: 'pro' }) to
- // every subsequent event. Values become OTLP attributes prefixed
- // with "context.".
- contextAttrs = Object.assign({}, contextAttrs, context || {});
- flush(); // ensure earlier queued events don't mix contexts ambiguously
- }
- var contextAttrs = {};
-
- function onError(event) {
- // Resource load errors (img/script/link) surface as an ErrorEvent-less
- // Event targeting the failed element; script errors carry a message.
- var target = event.target;
- if (target && target !== global && target.tagName) {
- var tag = String(target.tagName).toLowerCase();
- if (tag === 'img' || tag === 'script' || tag === 'link') {
- var url = target.src || target.href || '';
- captureMessage('Failed to load ' + tag + ': ' + url, 'ERROR', {
- 'exception.type': 'ResourceLoadError',
- 'resource.tag': tag,
- 'resource.url': url
- });
- }
- return;
- }
-
- var message = event.message || 'Script error';
- captureException(event.error || { name: 'Error', message: message }, {
- 'source.file': event.filename || '',
- 'source.line': event.lineno,
- 'source.column': event.colno
- });
- }
-
- function onUnhandledRejection(event) {
- var reason = event.reason;
- captureException(reason instanceof Error ? reason : { name: 'UnhandledRejection', message: String(reason) }, {
- 'exception.type': (reason && reason.name) || 'UnhandledRejection'
- });
- }
-
- global.addEventListener('error', onError);
- // Resource load errors don't bubble - must listen on the capturing phase.
- global.addEventListener('error', onError, true);
- global.addEventListener('unhandledrejection', onUnhandledRejection);
-
- if (captureConsoleErrors && global.console && typeof global.console.error === 'function') {
- var originalConsoleError = global.console.error;
- global.console.error = function () {
- try {
- addBreadcrumb('console.error', Array.prototype.slice.call(arguments).join(' '));
- } catch (e) {
- /* ignore */
- }
- return originalConsoleError.apply(global.console, arguments);
- };
- }
-
- function shouldInject(url) {
- if (!injectTraceHeaders) return false;
- if (!traceHeaderOrigins) return isSameOrigin(url);
- return traceHeaderOrigins.some(function (o) {
- return url.indexOf(o) === 0;
- });
- }
-
- // httpFailureReport reports a failed HTTP round-trip as an ERROR event,
- // so backend 5xx/4xx responses show up in the Issues view even when
- // the app code swallows them.
- function httpFailureReport(kind, method, url, status, traceId, extra) {
- // Issue refresh failures must not create new issues notifications:
- // otherwise an expired session can form an API-error feedback loop.
- try {
- if (new global.URL(url, global.location.href).pathname === '/api/issues') return;
- } catch (e) {}
- var attrs = Object.assign(
- {
- 'exception.type': 'HttpFailure',
- 'http.request.method': method,
- 'url.full': String(url).slice(0, 500),
- 'http.response.status_code': status === undefined || status === 0 ? '' : String(status)
- },
- extra || {}
- );
- captureMessage(
- method + ' ' + url + ' failed with ' + (status === undefined || status === 0 ? 'network error' : 'HTTP ' + status),
- 'ERROR',
- attrs
- );
- }
-
- if (autoInstrumentFetch && typeof global.fetch === 'function') {
- var originalFetch = global.fetch;
- global.fetch = function (input, requestInit) {
- var url = typeof input === 'string' ? input : (input && input.url) || '';
- if (isCollectorUrl(url)) {
- return originalFetch.call(global, input, requestInit);
- }
- var method = (requestInit && requestInit.method) || (input && input.method) || 'GET';
- var requestTraceId = '';
-
- if (shouldInject(url)) {
- requestInit = requestInit || {};
- var headers = new global.Headers(requestInit.headers || (input && input.headers) || {});
- var th = traceHeaders();
- headers.set('traceparent', th.traceparent);
- requestTraceId = th.traceparent.slice(3, 35); // trace id portion
- requestInit = Object.assign({}, requestInit, { headers: headers });
- }
-
- var startedAt = Date.now();
- return originalFetch.call(global, input, requestInit).then(
- function (res) {
- addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status, { d: Date.now() - startedAt, tid: requestTraceId });
- if (reportHttpFailures && res.status >= 400) {
- httpFailureReport('fetch', method, url, res.status, requestTraceId);
- }
- return res;
- },
- function (err) {
- addBreadcrumb('fetch', method + ' ' + url + ' -> error', { d: Date.now() - startedAt, tid: requestTraceId });
- if (reportHttpFailures) {
- httpFailureReport('fetch', method, url, 0, requestTraceId);
- }
- throw err;
- }
- );
- };
- }
-
- // XHR instrumentation: trace-header injection + breadcrumbs + failure
- // reporting, so legacy code paths (axios older versions, jQuery, etc.)
- // get the same visibility as fetch.
- if (autoInstrumentXhr && typeof global.XMLHttpRequest === 'function') {
- var originalOpen = global.XMLHttpRequest.prototype.open;
- var originalSend = global.XMLHttpRequest.prototype.send;
- var originalSetRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader;
-
- global.XMLHttpRequest.prototype.open = function (method, url) {
- if (isCollectorUrl(url)) {
- return originalOpen.apply(this, arguments);
- }
- try {
- this.__rum = { method: String(method), url: String(url) };
- if (shouldInject(String(url))) {
- var th = traceHeaders();
- this.__rum.traceId = th.traceparent.slice(3, 35);
- originalSetRequestHeader.call(this, 'traceparent', th.traceparent);
- }
- } catch (e) {
- /* ignore */
- }
- return originalOpen.apply(this, arguments);
- };
-
- global.XMLHttpRequest.prototype.send = function () {
- var xhr = this;
- var info = xhr.__rum || {};
- var xhrStartedAt = Date.now();
- if (info.url) {
- var origHandler = xhr.onreadystatechange;
- xhr.onreadystatechange = function () {
- try {
- if (xhr.readyState === 4) {
- addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status, { d: Date.now() - xhrStartedAt, tid: info.traceId });
- if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) {
- httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId);
- }
- }
- } catch (e) {
- /* ignore */
- }
- if (origHandler) return origHandler.apply(this, arguments);
- };
- }
- return originalSend.apply(this, arguments);
- };
- }
-
- // SPA route-change breadcrumbs: history.pushState/replaceState and
- // popstate/hashes get recorded so an error report shows which route
- // transitions preceded it.
- if (trackSpaRoutes && global.history) {
- function recordRoute(kind) {
- addBreadcrumb('navigation', kind + ' ' + (global.location ? global.location.pathname + global.location.search : ''));
- }
- var originalPush = global.history.pushState;
- var originalReplace = global.history.replaceState;
- if (typeof originalPush === 'function') {
- global.history.pushState = function () {
- var result = originalPush.apply(this, arguments);
- try {
- recordRoute('pushState');
- } catch (e) {}
- return result;
- };
- }
- if (typeof originalReplace === 'function') {
- global.history.replaceState = function () {
- var result = originalReplace.apply(this, arguments);
- try {
- recordRoute('replaceState');
- } catch (e) {}
- return result;
- };
- }
- global.addEventListener('popstate', function () {
- try {
- recordRoute('popstate');
- } catch (e) {}
- });
- }
-
- // Flush pending events when the page is being hidden or unloaded so
- // errors that happen right before navigation are not lost, and emit
- // the final CLS value.
- global.addEventListener('pagehide', function () {
- flush();
- });
- global.addEventListener('visibilitychange', function () {
- if (global.document && global.document.visibilityState === 'hidden') {
- flush();
- }
- });
-
- // First paint: report vitals after observers have a chance to buffer.
- if (captureWebVitals) {
- observeVitals();
- }
-
- // extra.d = request duration in ms, extra.tid = trace id we sent on the
- // request. t stays the completion time; the request started at t - d.
- function addBreadcrumb(category, message, extra) {
- var crumb = { t: Date.now(), category: String(category), message: String(message) };
- if (extra && typeof extra.d === 'number') crumb.d = extra.d;
- if (extra && extra.tid) crumb.tid = String(extra.tid);
- breadcrumbs.push(crumb);
- if (breadcrumbs.length > maxBreadcrumbs) {
- breadcrumbs.shift();
- }
- }
-
- var instance = {
- captureException: captureException,
- captureMessage: captureMessage,
- addBreadcrumb: addBreadcrumb,
- setContext: setContext,
- flush: flush,
- newTraceId: newTraceId,
- newSpanId: newSpanId,
- traceHeaders: traceHeaders
- };
-
- // Convenience handle for manual/console verification:
- // OtelRUM.last.captureMessage('test from console').
- global.OtelRUM.last = instance;
- return instance;
- }
-
- global.OtelRUM = { init: init, newTraceId: newTraceId, newSpanId: newSpanId, last: undefined };
-
- // Auto-initialize if data attributes are declared on the
- *
- * Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name
- * (the project), data-ingest-key, data-components (comma list, also
- * discovered from [data-component] elements on the page), data-shortcut
- * (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true".
- */
-(function (global) {
- 'use strict';
- var DEFAULT_BASE_URL = 'https://otel.k6n.net';
- var scriptEl = document.currentScript;
-
- function attr(name) {
- return (scriptEl && scriptEl.getAttribute(name)) || '';
- }
-
- function parseShortcut(s) {
- var parts = String(s).toLowerCase().split('+');
- var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false };
- parts.forEach(function (p) {
- if (p === 'alt' || p === 'option') sc.alt = true;
- else if (p === 'shift') sc.shift = true;
- else if (p === 'ctrl' || p === 'control') sc.ctrl = true;
- else if (p === 'meta' || p === 'cmd') sc.meta = true;
- });
- return sc;
- }
-
- function init(config) {
- config = config || {};
- if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance;
-
- var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, '');
- var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos';
- var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend';
- var ingestKey = config.ingestKey || attr('data-ingest-key');
- var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t');
- var hideButton = config.hideButton || attr('data-hide-button') === 'true';
- var staticComponents = config.components || attr('data-components').split(',');
- staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean);
-
- var sessionId = '';
- try { sessionId = global.sessionStorage.getItem('otel-rum-session') || ''; } catch (e) {}
-
- var css = document.createElement('style');
- css.textContent =
- '.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' +
- 'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' +
- '.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' +
- 'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' +
- 'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' +
- 'dialog.otel-todo *{box-sizing:border-box;font:inherit}' +
- 'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' +
- 'dialog.otel-todo textarea{min-height:90px;resize:vertical}' +
- 'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' +
- 'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
- 'dialog.otel-todo .status{margin-right:auto;font-size:12px}' +
- '.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;' +
- 'background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}' +
- '.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}' +
- '.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}' +
- '.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;' +
- 'border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}' +
- '.otel-todo-ref.show{display:flex}' +
- '.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
- '.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}' +
- '.otel-todo-ref button:hover{opacity:1}' +
- '.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;' +
- 'background:#3b82f626;border-radius:3px;transition:all .05s linear}' +
- '.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;' +
- 'background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;' +
- 'box-shadow:0 4px 14px #0004}';
- document.head.appendChild(css);
-
- var dlg = document.createElement('dialog');
- dlg.className = 'otel-todo';
- dlg.setAttribute('aria-label', 'Add todo');
- dlg.innerHTML =
- '';
- var form = dlg.firstChild;
- var textEl = form.elements.text;
- var selectEl = form.elements.component;
- var statusEl = form.querySelector('.status');
- var metaEl = form.querySelector('.meta');
- var pickBtn = form.querySelector('.otel-todo-pick');
- var refEl = form.querySelector('.otel-todo-ref');
- var refLabelEl = refEl.querySelector('span');
- document.body.appendChild(dlg);
-
- // --- element picker: click a node on the page to attach its selector ---
- var picked = null; // { selector, label }
- var hoverBox = null;
- var picking = false;
- var hint = null;
-
- function cssPath(el) {
- if (!(el instanceof Element)) return '';
- var parts = [];
- var node = el;
- while (node && node.nodeType === 1 && parts.length < 6) {
- if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
- var part = node.tagName.toLowerCase();
- var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
- if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
- var parent = node.parentElement;
- if (parent) {
- var siblings = [].filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
- if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
- }
- parts.unshift(part);
- node = parent;
- }
- return parts.join(' > ');
- }
-
- function describeElement(el) {
- var tag = el.tagName.toLowerCase();
- var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
- if (text.length > 60) text = text.slice(0, 60) + '…';
- return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag };
- }
-
- function setRef(ref) {
- picked = ref;
- if (ref) {
- refLabelEl.textContent = ref.label;
- refLabelEl.title = ref.selector;
- refEl.classList.add('show');
- } else {
- refEl.classList.remove('show');
- }
- }
-
- function ensureHoverBox() {
- if (!hoverBox) {
- hoverBox = document.createElement('div');
- hoverBox.className = 'otel-todo-hover-outline';
- document.body.appendChild(hoverBox);
- }
- return hoverBox;
- }
-
- function onPickMove(e) {
- var el = document.elementFromPoint(e.clientX, e.clientY);
- if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; }
- var r = el.getBoundingClientRect();
- var box = ensureHoverBox();
- box.style.display = 'block';
- box.style.left = r.left + 'px';
- box.style.top = r.top + 'px';
- box.style.width = r.width + 'px';
- box.style.height = r.height + 'px';
- }
-
- function onPickClick(e) {
- var el = document.elementFromPoint(e.clientX, e.clientY);
- if (!el || dlg.contains(el)) return; // let dialog controls work normally
- e.preventDefault();
- e.stopPropagation();
- setRef(describeElement(el));
- stopPicking();
- }
-
- function onPickKey(e) {
- if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); }
- }
-
- function startPicking() {
- if (picking) return;
- picking = true;
- pickBtn.setAttribute('aria-pressed', 'true');
- pickBtn.textContent = '⌖ Click an element… (Esc to cancel)';
- hint = document.createElement('div');
- hint.className = 'otel-todo-pick-hint';
- hint.textContent = 'Click the element you mean — Esc to cancel';
- document.body.appendChild(hint);
- document.addEventListener('mousemove', onPickMove, true);
- document.addEventListener('click', onPickClick, true);
- document.addEventListener('keydown', onPickKey, true);
- }
-
- function stopPicking() {
- if (!picking) return;
- picking = false;
- pickBtn.setAttribute('aria-pressed', 'false');
- pickBtn.textContent = '⌖ Reference an element';
- document.removeEventListener('mousemove', onPickMove, true);
- document.removeEventListener('click', onPickClick, true);
- document.removeEventListener('keydown', onPickKey, true);
- if (hoverBox) { hoverBox.style.display = 'none'; }
- if (hint) { hint.remove(); hint = null; }
- }
-
- pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); });
- refEl.querySelector('[data-clear-ref]').addEventListener('click', function () { setRef(null); });
-
- function components() {
- var seen = {};
- var list = [];
- staticComponents.concat([].map.call(document.querySelectorAll('[data-component]'), function (el) {
- return el.getAttribute('data-component');
- })).forEach(function (c) {
- if (c && !seen[c]) { seen[c] = true; list.push(c); }
- });
- return list;
- }
-
- function open() {
- if (dlg.open) { textEl.focus(); return; }
- selectEl.innerHTML = '';
- var none = document.createElement('option');
- none.value = '';
- none.textContent = 'No component';
- selectEl.appendChild(none);
- var list = components();
- list.forEach(function (c) {
- var o = document.createElement('option');
- o.value = o.textContent = c;
- selectEl.appendChild(o);
- });
- selectEl.hidden = list.length === 0;
- metaEl.textContent = project + ' · ' + location.pathname + location.hash;
- statusEl.textContent = '';
- setRef(null);
- // Non-modal so the page stays usable while the note is being written.
- dlg.show();
- textEl.focus();
- }
-
- function close() {
- stopPicking();
- if (dlg.open) dlg.close();
- }
-
- form.querySelector('[data-cancel]').addEventListener('click', close);
- dlg.addEventListener('keydown', function (e) {
- if (e.key === 'Escape') { e.preventDefault(); close(); }
- else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
- });
- form.addEventListener('submit', function (e) {
- e.preventDefault();
- var text = textEl.value.trim();
- if (!text) return;
- var headers = { 'Content-Type': 'application/json' };
- var url = endpoint;
- if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey;
- statusEl.textContent = 'Sending…';
- global.fetch(url, {
- method: 'POST',
- mode: 'cors',
- headers: headers,
- body: JSON.stringify({
- project: project,
- text: text,
- component: selectEl.value,
- path: location.pathname,
- hash: location.hash,
- url: location.href,
- ua: navigator.userAgent,
- session: sessionId,
- elementSelector: picked ? picked.selector : '',
- elementLabel: picked ? picked.label : ''
- })
- }).then(function (res) {
- if (!res.ok) throw new Error('HTTP ' + res.status);
- textEl.value = '';
- setRef(null);
- close();
- }).catch(function (err) {
- statusEl.textContent = 'Failed: ' + err.message;
- });
- });
-
- global.addEventListener('keydown', function (e) {
- var k = e.key.toLowerCase();
- var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase();
- if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift &&
- e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) {
- e.preventDefault();
- dlg.open ? close() : open();
- }
- });
-
- if (!hideButton) {
- var btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'otel-todo-btn';
- btn.textContent = '✎';
- btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')';
- btn.setAttribute('aria-label', 'Add todo');
- btn.addEventListener('click', function () { dlg.open ? close() : open(); });
- document.body.appendChild(btn);
- }
-
- var instance = { open: open, close: close };
- global.OtelTodo.instance = instance;
- return instance;
- }
-
- global.OtelTodo = { init: init, instance: undefined };
-
- if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) {
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); });
- else init();
- }
-})(window);
+"use strict";(()=>{(function(u){"use strict";var X="https://otel.k6n.net",S=document.currentScript;function i(o){return S&&S.getAttribute(o)||""}function G(o){var k=String(o).toLowerCase().split("+"),p={key:k.pop(),alt:!1,shift:!1,ctrl:!1,meta:!1};return k.forEach(function(l){l==="alt"||l==="option"?p.alt=!0:l==="shift"?p.shift=!0:l==="ctrl"||l==="control"?p.ctrl=!0:(l==="meta"||l==="cmd")&&(p.meta=!0)}),p}function A(o={}){if(o=o||{},u.OtelTodo&&u.OtelTodo.instance)return u.OtelTodo.instance;var k=(o.baseUrl||i("data-base-url")||X).replace(/\/+$/,""),p=o.endpoint||i("data-todo-endpoint")||k+"/v1/todos",l=o.serviceName||i("data-service-name")||i("data-service")||"frontend",K=o.ingestKey||i("data-ingest-key"),h=G(o.shortcut||i("data-shortcut")||"alt+shift+t"),Y=o.hideButton||i("data-hide-button")==="true",M=o.components||i("data-components").split(",");M=M.map(function(e){return e.trim()}).filter(Boolean);var D=(o.consent||i("data-consent"))==="granted"?"granted":"denied";function N(){return o.getConsent?o.getConsent():D}function _(e){D=e==="granted"?"granted":"denied"}var q=document.createElement("style");q.textContent='.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}dialog.otel-todo *{box-sizing:border-box;font:inherit}dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}dialog.otel-todo textarea{min-height:90px;resize:vertical}dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}dialog.otel-todo .status{margin-right:auto;font-size:12px}.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}.otel-todo-ref.show{display:flex}.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}.otel-todo-ref button:hover{opacity:1}.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;background:#3b82f626;border-radius:3px;transition:all .05s linear}.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;box-shadow:0 4px 14px #0004}',document.head.appendChild(q);var a=document.createElement("dialog");a.className="otel-todo",a.setAttribute("aria-label","Add todo"),a.innerHTML='';var s=a.firstElementChild,C=s.elements.namedItem("text"),g=s.elements.namedItem("component"),H=s.querySelector(".status"),J=s.querySelector(".meta"),b=s.querySelector(".otel-todo-pick"),L=s.querySelector(".otel-todo-ref"),O=L.querySelector("span");document.body.appendChild(a);var y=null,f=null,E=!1,m=null;function W(e){if(!(e instanceof Element))return"";for(var n=[],t=e;t&&t.nodeType===1&&n.length<6;){if(t.id){n.unshift("#"+CSS.escape(t.id));break}var r=t.tagName.toLowerCase(),d=(t.getAttribute("class")||"").trim().split(/\s+/).filter(Boolean).slice(0,2);d.length&&(r+="."+d.map(function(B){return CSS.escape(B)}).join("."));var c=t.parentElement;if(c){var Z=t.tagName,U=Array.from(c.children).filter(function(B){return B.tagName===Z});U.length>1&&(r+=":nth-of-type("+(U.indexOf(t)+1)+")")}n.unshift(r),t=c}return n.join(" > ")}function $(e){var n=e.tagName.toLowerCase(),t=(e.getAttribute("aria-label")||e.textContent||"").replace(/\s+/g," ").trim();return t.length>60&&(t=t.slice(0,60)+"\u2026"),{selector:W(e),label:t?n+' \u2013 "'+t+'"':n}}function w(e){y=e,e?(O.textContent=e.label,O.title=e.selector,L.classList.add("show")):L.classList.remove("show")}function z(){return f||(f=document.createElement("div"),f.className="otel-todo-hover-outline",document.body.appendChild(f)),f}function R(e){var n=document.elementFromPoint(e.clientX,e.clientY);if(!n||a.contains(n)){z().style.display="none";return}var t=n.getBoundingClientRect(),r=z();r.style.display="block",r.style.left=t.left+"px",r.style.top=t.top+"px",r.style.width=t.width+"px",r.style.height=t.height+"px"}function I(e){var n=document.elementFromPoint(e.clientX,e.clientY);!n||a.contains(n)||(e.preventDefault(),e.stopPropagation(),w($(n)),T())}function j(e){e.key==="Escape"&&(e.preventDefault(),e.stopPropagation(),T())}function Q(){E||(E=!0,b.setAttribute("aria-pressed","true"),b.textContent="\u2316 Click an element\u2026 (Esc to cancel)",m=document.createElement("div"),m.className="otel-todo-pick-hint",m.textContent="Click the element you mean \u2014 Esc to cancel",document.body.appendChild(m),document.addEventListener("mousemove",R,!0),document.addEventListener("click",I,!0),document.addEventListener("keydown",j,!0))}function T(){E&&(E=!1,b.setAttribute("aria-pressed","false"),b.textContent="\u2316 Reference an element",document.removeEventListener("mousemove",R,!0),document.removeEventListener("click",I,!0),document.removeEventListener("keydown",j,!0),f&&(f.style.display="none"),m&&(m.remove(),m=null))}b.addEventListener("click",function(){E?T():Q()}),L.querySelector("[data-clear-ref]").addEventListener("click",function(){w(null)});function V(){var e={},n=[];return M.concat(Array.from(document.querySelectorAll("[data-component]"),function(t){return t.getAttribute("data-component")||""})).forEach(function(t){t&&!e[t]&&(e[t]=!0,n.push(t))}),n}function P(){if(a.open){C.focus();return}g.innerHTML="";var e=document.createElement("option");e.value="",e.textContent="No component",g.appendChild(e);var n=V();n.forEach(function(t){var r=document.createElement("option");r.value=r.textContent=t,g.appendChild(r)}),g.hidden=n.length===0,J.textContent=l+" \xB7 "+location.pathname+location.hash,H.textContent="",w(null),a.show(),C.focus()}function x(){T(),a.open&&a.close()}if(s.querySelector("[data-cancel]").addEventListener("click",x),a.addEventListener("keydown",function(e){e.key==="Escape"?(e.preventDefault(),x()):e.key==="Enter"&&(e.ctrlKey||e.metaKey)&&(e.preventDefault(),s.requestSubmit())}),s.addEventListener("submit",function(e){e.preventDefault();var n=C.value.trim();if(n){var t={"Content-Type":"application/json"},r=p;K&&(t["X-Otel-Ingest-Key"]=K);var d=N()==="granted";H.textContent="Sending\u2026",u.fetch(r,{method:"POST",mode:"cors",credentials:"omit",referrerPolicy:"no-referrer",headers:t,body:JSON.stringify({project:l,text:n,component:g.value,path:d?location.pathname:"",hash:d?location.hash:"",url:d?location.href:"",ua:d?navigator.userAgent:"",session:d&&o.getSessionId?o.getSessionId():"",elementSelector:y?y.selector:"",elementLabel:y?y.label:""})}).then(function(c){if(!c.ok)throw new Error("HTTP "+c.status);C.value="",w(null),x()}).catch(function(c){H.textContent="Failed: "+c.message})}}),u.addEventListener("keydown",function(e){var n=e.key.toLowerCase(),t=(e.code||"").replace(/^(Key|Digit)/,"").toLowerCase();(n===h.key||t===h.key)&&e.altKey===h.alt&&e.shiftKey===h.shift&&e.ctrlKey===h.ctrl&&e.metaKey===h.meta&&(e.preventDefault(),a.open?x():P())}),!Y){var v=document.createElement("button");v.type="button",v.className="otel-todo-btn",v.textContent="\u270E",v.title="Add todo ("+(o.shortcut||i("data-shortcut")||"alt+shift+t")+")",v.setAttribute("aria-label","Add todo"),v.addEventListener("click",function(){a.open?x():P()}),document.body.appendChild(v)}var F={open:P,close:x,getConsent:N,setConsent:_};return u.OtelTodo.instance=F,F}u.OtelTodo={init:A,instance:void 0},S&&(i("data-auto-init")==="true"||i("data-service-name")||i("data-service"))&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){A()}):A())})(window);})();