From 193e6c6e39ebf47d6ca7e6057cedb4311b34c14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Wed, 4 Nov 2026 13:04:34 +0100 Subject: [PATCH] update rum --- .gitignore | 3 + Dockerfile | 12 +- Makefile | 13 +- docs/EU-RUM-PRIVACY.md | 164 ++++++++ instrumentation.md | 211 +++++++++- rum/build.mjs | 15 + rum/package-lock.json | 512 ++++++++++++++++++++++++ rum/package.json | 14 + rum/src/otel-rum.ts | 821 ++++++++++++++++++++++++++++++++++++++ rum/src/otel-todo.ts | 338 ++++++++++++++++ rum/src/types.ts | 132 ++++++ rum/tsconfig.json | 15 + scripts/otel-rum.test.mjs | 516 ++++++++++++++++++++++++ static/otel-rum.js | 746 +--------------------------------- static/otel-todo.js | 328 +-------------- 15 files changed, 2760 insertions(+), 1080 deletions(-) create mode 100644 docs/EU-RUM-PRIVACY.md create mode 100644 rum/build.mjs create mode 100644 rum/package-lock.json create mode 100644 rum/package.json create mode 100644 rum/src/otel-rum.ts create mode 100644 rum/src/otel-todo.ts create mode 100644 rum/src/types.ts create mode 100644 rum/tsconfig.json create mode 100644 scripts/otel-rum.test.mjs 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);})();