update rum
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndPushAmd64 (push) Failing after 1m27s

This commit is contained in:
mats committed 2026-11-04 13:04:34 +01:00
1 parent 7b68f60202
commit 193e6c6e39
15 files changed
+2760 -1080

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ otel-agent.log
/otel-ui/node_modules/ /otel-ui/node_modules/
/otel-ui/dist/ /otel-ui/dist/
/otel-ui/dist-ssr/ /otel-ui/dist-ssr/
/rum/node_modules/
/static/otel-rum.js.map
/static/otel-todo.js.map
# Local credentials and scratch # Local credentials and scratch
.env .env
+11 -1
View File
@@ -15,6 +15,15 @@ COPY otel-ui/ .
RUN npm install && npm run build 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 # Go build stage
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS go-builder 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 /go-otel .
COPY --from=go-builder /app/otel-ui/dist ./dist COPY --from=go-builder /app/otel-ui/dist ./dist
COPY static/ ./static COPY static/ ./static
COPY --from=rum-builder /app/static/ ./static/
COPY db/GeoLite2-City.mmdb db/GeoLite2-ASN.mmdb ./db/ COPY db/GeoLite2-City.mmdb db/GeoLite2-ASN.mmdb ./db/
COPY agents_example.md . COPY agents_example.md .
COPY instrumentation.md . COPY instrumentation.md .
# Private server-side maps are the default. Staging/dev can opt into public # Private server-side maps are the default. Staging/dev can opt into public
# maps for Chrome DevTools with --build-arg PUBLIC_SOURCEMAPS=true. # 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 # Ensure the appuser owns the files
RUN chown -R appuser:appgroup /home/appuser/ RUN chown -R appuser:appgroup /home/appuser/
+12 -1
View File
@@ -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: lint:
@which golangci-lint > /dev/null || (echo "golangci-lint not found, installing..." && make install-lint) @which golangci-lint > /dev/null || (echo "golangci-lint not found, installing..." && make install-lint)
+164
View File
@@ -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.
+205 -6
View File
@@ -31,6 +31,21 @@ Load the RUM script and initialize it once, before the application starts:
</script> </script>
``` ```
To avoid parser-blocking downloads, use a deferred script with declarative
initialization instead of the two scripts above:
```html
<script defer src="https://otel.example.com/static/otel-rum.js"
data-service-name="checkout-web"
data-release="2026.09.18-a1b2c3"
data-environment="production"
data-base-url="https://otel.example.com"></script>
```
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 The collector captures uncaught errors, unhandled promise rejections, failed
HTTP requests, breadcrumbs, Web Vitals, and optional trace context. Correlate HTTP requests, breadcrumbs, Web Vitals, and optional trace context. Correlate
frontend requests with backend spans when possible: frontend requests with backend spans when possible:
@@ -52,6 +67,170 @@ try {
Do not put secrets, tokens, or sensitive customer data in breadcrumbs or Do not put secrets, tokens, or sensitive customer data in breadcrumbs or
custom attributes. 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 ## React error boundaries
Rendering errors are not reliably caught by global `window.onerror` handlers. 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 `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 (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 from the page, with an optional component picker. User-submitted text, selected
are posted with it. 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 Todo is **disabled by default** in `otel-rum.js`. Opt in with
script) and reuses its service name, base URL and ingest key. Opt out with `OtelRUM.init({ enableTodo: true })` or `data-enable-todo="true"` on the RUM
`OtelRUM.init({ excludeTodo: true })` or `data-exclude-todo="true"`. Or load it script. It reuses the service name, base URL and ingest key, and loads from
standalone: 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 ```html
<script src="https://otel.example.com/static/otel-todo.js" <script src="https://otel.example.com/static/otel-todo.js"
@@ -111,6 +304,12 @@ standalone:
Components are also discovered from `[data-component="..."]` elements. Other Components are also discovered from `[data-component="..."]` elements. Other
options (on the RUM or todo script, or `OtelRUM.init({ todo: {...} })`): `data-shortcut`, `data-hide-button="true"`, `data-base-url`. options (on the RUM or todo script, or `OtelRUM.init({ todo: {...} })`): `data-shortcut`, `data-hide-button="true"`, `data-base-url`.
When loaded by RUM, Todo reads the current RUM consent at submission time,
including after withdrawal. Standalone Todo defaults to denied consent; use
`OtelTodo.instance.setConsent("granted" | "denied")` from the consent manager,
or initialize with `consent` / `data-consent`. Submissions always omit browser
credentials and referrers, and no browser storage is used.
- `POST /v1/todos` (ingest key, CORS) body `{project, text, component, path, hash, url, ua, session}` - `POST /v1/todos` (ingest key, CORS) body `{project, text, component, path, hash, url, ua, session}`
- `GET /api/todos?project=<service>&limit=100` (authenticated; omit `project` for all), newest first - `GET /api/todos?project=<service>&limit=100` (authenticated; omit `project` for all), newest first
+15
View File
@@ -0,0 +1,15 @@
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
await build({
absWorkingDir: fileURLToPath(new URL('.', import.meta.url)),
entryPoints: ['src/otel-rum.ts', 'src/otel-todo.ts'],
outdir: '../static',
bundle: true,
format: 'iife',
platform: 'browser',
target: ['es2020'],
minify: true,
sourcemap: 'external',
logLevel: 'info',
});
+512
View File
@@ -0,0 +1,512 @@
{
"name": "go-otel-rum",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "go-otel-rum",
"devDependencies": {
"esbuild": "^0.28.1",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "go-otel-rum",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && node build.mjs",
"test": "npm run build && node --test ../scripts/otel-rum.test.mjs"
},
"devDependencies": {
"esbuild": "^0.28.1",
"typescript": "^5.9.3"
}
}
+821
View File
@@ -0,0 +1,821 @@
import type { Attributes, BrowserGlobal, Consent, ExceptionLike, RumConfig, VitalMetric } from './types';
interface Breadcrumb {
t: number;
category: string;
message: string;
d?: number;
tid?: string;
}
interface LogRecord {
timeUnixNano: string;
severityText: string;
severityNumber: number;
body: { stringValue: string };
attributes: { key: string; value: { stringValue: string } }[];
traceId?: string;
}
(function (global: BrowserGlobal) {
'use strict';
var DEFAULT_BASE_URL = 'https://otel.k6n.net';
// Find the loading script so declarative data-* configuration keeps working.
var scriptEl: HTMLScriptElement | null = null;
try {
scriptEl = (typeof document !== 'undefined') ? (document.currentScript as HTMLScriptElement | null || (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: number) {
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: string) {
try {
var resolved = new global.URL(url, global.location.href);
return resolved.origin === global.location.origin;
} catch (e) {
return false;
}
}
function toOtlpAttributes(obj: Attributes) {
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: RumConfig = {}) {
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: string | URL) {
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 consent: Consent = (config.consent || (scriptEl && scriptEl.getAttribute('data-consent'))) === 'granted' ? 'granted' : 'denied';
var devRequested = typeof config.devMode === 'boolean' ? config.devMode : !!(scriptEl && scriptEl.getAttribute('data-dev-mode') === 'true');
var devHosts = config.devModeHosts || ((scriptEl && scriptEl.getAttribute('data-dev-mode-hosts')) || '').split(',').map(function (host) { return host.trim(); }).filter(Boolean);
var devMode = false;
try {
devMode = devRequested && devHosts.indexOf(new global.URL(global.location.href).hostname) !== -1;
} catch (e) {}
var minimalDiagnostics = config.minimalDiagnostics;
var consentListeners: (() => void)[] = [];
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;
var captureWebVitalsWithoutConsent = typeof config.captureWebVitalsWithoutConsent === 'boolean' ? config.captureWebVitalsWithoutConsent : !!(scriptEl && scriptEl.getAttribute('data-vitals-without-consent') === 'true');
var vitalsStarted = false;
var enableTodo = typeof config.enableTodo === 'boolean' ? config.enableTodo : !!(scriptEl && scriptEl.getAttribute('data-enable-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: Breadcrumb[] = [];
// 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 = '';
var sessionId = hasDetailedDiagnostics() ? randomHex(16) : '';
// --- Client-side queue, batching and rate limiting -----------------
var queue: LogRecord[] = [];
var flushTimer: number | null = null;
var sentTimestamps: number[] = [];
var eventsDropped = 0;
function setConsent(next: Consent) {
var normalized: Consent = next === 'granted' ? 'granted' : 'denied';
var disableDev = normalized === 'denied' && devMode;
if (consent === normalized && !disableDev) return;
if (disableDev) devMode = false;
consent = normalized;
queue = [];
if (flushTimer) global.clearTimeout(flushTimer);
flushTimer = null;
breadcrumbs = [];
contextAttrs = {};
lastTraceId = '';
sessionId = hasDetailedDiagnostics() ? randomHex(16) : '';
consentListeners.forEach(function (reset) { reset(); });
startVitals();
}
function getConsent(): Consent {
return consent;
}
function hasDetailedDiagnostics() {
return consent === 'granted' || devMode;
}
function getPrivacyMode(): 'counts' | 'minimal' | 'consented' | 'development' {
if (devMode) return 'development';
if (consent === 'granted') return 'consented';
return minimalDiagnostics ? 'minimal' : 'counts';
}
function mayCaptureVitals() {
return captureWebVitals && (hasDetailedDiagnostics() || captureWebVitalsWithoutConsent);
}
function startVitals() {
if (vitalsStarted || !mayCaptureVitals()) return;
vitalsStarted = true;
observeVitals();
}
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: LogRecord) {
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);
}
function ship(records: LogRecord[]) {
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: Record<string, string> = { 'Content-Type': 'application/json' };
if (ingestKey) {
fetchHeaders['X-Otel-Ingest-Key'] = ingestKey;
}
fetchFn.call(global, url, {
method: 'POST',
mode: 'cors',
credentials: 'omit',
referrerPolicy: 'no-referrer',
headers: fetchHeaders,
body: body,
keepalive: true
}).catch(function () {});
return;
}
}
var severityNumbers: Record<string, number> = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17, FATAL: 21 };
function buildRecord(severity: string, message: string, attrs?: Attributes, traceId?: string) {
if (!hasDetailedDiagnostics()) {
var anonymousAttrs: Attributes = {
source: 'frontend',
'service.release': release,
'service.environment': environment,
'privacy.mode': 'counts'
};
var errorType = attrs && attrs['exception.type'];
if (typeof errorType === 'string' && ['Error', 'TypeError', 'ReferenceError', 'SyntaxError', 'RangeError', 'URIError', 'EvalError', 'UnhandledRejection', 'HttpFailure', 'ResourceLoadError'].indexOf(errorType) !== -1) {
anonymousAttrs['exception.type'] = errorType;
}
var status = attrs && String(attrs['http.response.status_code'] || '');
if (status && /^[1-5][0-9]{2}$/.test(status)) anonymousAttrs['http.response.status_code'] = status;
var diagnosticMessage = 'Frontend event';
if (minimalDiagnostics) {
try {
var diagnostic = minimalDiagnostics.sanitize({
message: String(message).slice(0, 2000),
type: typeof errorType === 'string' ? errorType.slice(0, 100) : undefined,
stacktrace: attrs && typeof attrs['exception.stacktrace'] === 'string' ? attrs['exception.stacktrace'].slice(0, 10000) : undefined
});
if (diagnostic && typeof diagnostic.message === 'string' && diagnostic.message.trim()) {
diagnosticMessage = diagnostic.message.slice(0, 2000);
anonymousAttrs['privacy.mode'] = 'minimal';
anonymousAttrs['exception.message'] = diagnosticMessage;
if (typeof diagnostic.code === 'string') anonymousAttrs['error.code'] = diagnostic.code.slice(0, 100);
if (typeof diagnostic.stacktrace === 'string') anonymousAttrs['exception.stacktrace'] = diagnostic.stacktrace.slice(0, 10000);
if (minimalDiagnostics.route) anonymousAttrs['http.route'] = minimalDiagnostics.route.slice(0, 300);
}
} catch (e) {}
}
return {
timeUnixNano: String(Date.now() * 1e6),
severityText: Object.prototype.hasOwnProperty.call(severityNumbers, severity) ? severity : 'ERROR',
severityNumber: severityNumbers[severity] || 17,
body: { stringValue: diagnosticMessage },
attributes: toOtlpAttributes(anonymousAttrs)
};
}
var record: LogRecord = {
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',
'privacy.mode': getPrivacyMode(),
'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: string, message: string, attrs?: Attributes, traceId?: string) {
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: Record<string, { value: unknown; delta: unknown; id: string; attribution: Attributes }> = {};
var navType = '';
var dirty = false;
var sampled = sampleRate >= 1 || Math.random() <= sampleRate;
consentListeners.push(function () {
pageViewId = randomHex(16);
vitals = {};
dirty = false;
});
var picks: Record<string, string[]> = {
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: unknown) {
return typeof n === 'number' ? Math.round(n * 1000) / 1000 : n;
}
function slim(metric: VitalMetric) {
var out: Attributes = {};
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: VitalMetric) {
if (!mayCaptureVitals()) return;
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 (!mayCaptureVitals() || !dirty || !sampled) return;
dirty = false;
var granted = hasDetailedDiagnostics();
var anonymousMetrics: Record<string, { value: unknown; delta: unknown; attribution: Attributes }> = {};
Object.keys(vitals).forEach(function (name) {
var attribution: Attributes = {};
Object.keys(vitals[name].attribution).forEach(function (key) {
var value = vitals[name].attribution[key];
if (typeof value === 'number' && Number.isFinite(value)) attribution[key] = value;
});
anonymousMetrics[name] = { value: vitals[name].value, delta: vitals[name].delta, attribution: attribution };
});
var body = JSON.stringify({
service: serviceName,
environment: environment,
release: release,
session: sessionId,
pageView: pageViewId,
url: granted && global.location ? global.location.href : '',
ua: granted && global.navigator ? global.navigator.userAgent : '',
viewport: granted && global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '',
nav: navType,
conn: granted && (global.navigator && global.navigator.connection && global.navigator.connection.effectiveType) || '',
metrics: granted ? vitals : anonymousMetrics
});
var url = vitalsEndpoint;
if (ingestKey) url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey);
if (rawFetch) {
rawFetch.call(global, url, { method: 'POST', mode: 'cors', credentials: 'omit', referrerPolicy: 'no-referrer', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body, keepalive: true }).catch(function () {});
}
}
var script = global.document.createElement('script');
script.async = true;
script.referrerPolicy = 'no-referrer';
script.src = vitalsLibUrl;
script.onload = function () {
if (!mayCaptureVitals()) {
vitalsStarted = false;
return;
}
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({
getConsent: getConsent,
getSessionId: function () { return sessionId; },
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.referrerPolicy = 'no-referrer';
s.src = todoLibUrl;
s.onload = start;
global.document.head.appendChild(s);
}
if (enableTodo && !excludeTodo && global.document) {
function scheduleTodoWidget() {
if (typeof global.requestIdleCallback === 'function') {
global.requestIdleCallback(loadTodoWidget);
} else {
global.setTimeout(loadTodoWidget, 3000);
}
}
if (global.document.readyState === 'complete') scheduleTodoWidget();
else global.addEventListener('load', scheduleTodoWidget, { once: true });
}
// 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?: string) {
var traceId = existingTraceId || newTraceId();
var spanId = newSpanId();
lastTraceId = traceId;
return { traceparent: '00-' + traceId + '-' + spanId + '-01' };
}
function captureException(err: unknown, extra?: Attributes) {
var exception = err && typeof err === 'object' ? err as ExceptionLike : undefined;
var message = (exception && exception.message) || String(err);
send(
'ERROR',
message,
Object.assign(
{
'exception.type': (exception && exception.name) || 'Error',
'exception.message': message,
'exception.stacktrace': (exception && exception.stack) || ''
},
extra || {}
),
lastTraceId
);
}
function captureMessage(message: string, severity?: string, extra?: Attributes) {
send(severity || 'ERROR', message, extra, lastTraceId);
}
function setContext(context: Attributes) {
if (!hasDetailedDiagnostics()) return;
// 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: Attributes = {};
function onError(event: 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 as HTMLElement | null;
if (target && event.target !== global && target.tagName) {
var tag = String(target.tagName).toLowerCase();
if (tag === 'img' || tag === 'script' || tag === 'link') {
var url = (target as HTMLImageElement).src || (target as HTMLLinkElement).href || '';
captureMessage('Failed to load ' + tag + ': ' + url, 'ERROR', {
'exception.type': 'ResourceLoadError',
'resource.tag': tag,
'resource.url': url
});
}
return;
}
var errorEvent = event as ErrorEvent;
var message = errorEvent.message || 'Script error';
captureException(errorEvent.error || { name: 'Error', message: message }, {
'source.file': errorEvent.filename || '',
'source.line': errorEvent.lineno,
'source.column': errorEvent.colno
});
}
function onUnhandledRejection(event: PromiseRejectionEvent) {
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 (...args: unknown[]) {
try {
addBreadcrumb('console.error', args.join(' '));
} catch (e) {
/* ignore */
}
return originalConsoleError.apply(global.console, args);
};
}
function shouldInject(url: string) {
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: string, method: string, url: string, status?: number, traceId?: string, extra?: Attributes) {
// 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 request = typeof input !== 'string' && 'url' in input ? input : undefined;
var url = typeof input === 'string' ? input : request ? request.url : String(input);
if (isCollectorUrl(url)) {
return originalFetch.call(global, input, requestInit);
}
var method = (requestInit && requestInit.method) || (request && request.method) || 'GET';
var requestTraceId = '';
if (shouldInject(url)) {
requestInit = requestInit || {};
var headers = new global.Headers(requestInit.headers || (request && request.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 as unknown as Parameters<XMLHttpRequest['open']>);
}
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 as unknown as Parameters<XMLHttpRequest['open']>);
};
global.XMLHttpRequest.prototype.send = function () {
var xhr = this;
const info = xhr.__rum;
var xhrStartedAt = Date.now();
if (info && 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 as unknown as [Event]);
};
}
return originalSend.apply(this, arguments as unknown as Parameters<XMLHttpRequest['send']>);
};
}
// 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: string) {
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 as unknown as Parameters<History['pushState']>);
try {
recordRoute('pushState');
} catch (e) {}
return result;
};
}
if (typeof originalReplace === 'function') {
global.history.replaceState = function () {
var result = originalReplace.apply(this, arguments as unknown as Parameters<History['replaceState']>);
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.
startVitals();
// 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: string, message: string, extra?: { d?: number; tid?: string }) {
if (!hasDetailedDiagnostics()) return;
var crumb: Breadcrumb = { 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 = {
setConsent: setConsent,
getConsent: getConsent,
getPrivacyMode: getPrivacyMode,
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 <script> element
try {
if (typeof document !== 'undefined' && scriptEl && (scriptEl.getAttribute('data-auto-init') === 'true' || scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
global.OtelRUM.init();
});
} else {
global.OtelRUM.init();
}
}
} catch (e) {}
})((typeof window !== 'undefined' ? window : globalThis) as BrowserGlobal);
+338
View File
@@ -0,0 +1,338 @@
/**
* otel-todo.js - file todos / issues straight from the page.
*
* Shows a small floating symbol and listens for a keyboard shortcut. Opens a
* native <dialog> in the bottom-right corner with a text box, an optional
* component picker, and an "Reference an element" toggle that lets you click
* any node on the page (with a hover outline) to attach its CSS selector and
* a short label to the note. POSTs the note (with the current path, hash and
* any picked element) to the collector's /v1/todos endpoint.
*
* <script src="https://otel.example.com/static/otel-todo.js"
* data-service-name="checkout-web"
* data-ingest-key="..."
* data-components="Header,Cart,Payment"></script>
*
* 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 =
'<form method="dialog">' +
'<div class="meta"></div>' +
'<button type="button" class="otel-todo-pick" aria-pressed="false">⌖ Reference an element</button>' +
'<div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">✕</button></div>' +
'<textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea>' +
'<select name="component" aria-label="Component (optional)"></select>' +
'<div class="row"><span class="status" role="status"></span>' +
'<button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div>' +
'</form>';
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<HTMLElement>('.status')!;
var metaEl = form.querySelector<HTMLElement>('.meta')!;
var pickBtn = form.querySelector<HTMLButtonElement>('.otel-todo-pick')!;
var refEl = form.querySelector<HTMLElement>('.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<string, boolean> = {};
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<string, string> = { '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);
+132
View File
@@ -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<string, unknown>;
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<string, unknown> & {
longestScript?: { entry?: { sourceURL?: string; sourceFunctionName?: string } };
};
}
declare global {
interface Window {
OtelRUM: RumAPI;
OtelTodo?: TodoAPI;
webVitals?: Partial<Record<'onLCP' | 'onINP' | 'onCLS' | 'onFCP' | 'onTTFB', (callback: (metric: VitalMetric) => void) => void>>;
}
interface Navigator {
connection?: { effectiveType?: string };
}
interface XMLHttpRequest {
__rum?: { method: string; url: string; traceId?: string };
}
}
export type BrowserGlobal = Window & typeof globalThis;
+15
View File
@@ -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"]
}
+516
View File
@@ -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/);
});
+1 -745
View File
@@ -1,745 +1 @@
/*! "use strict";(()=>{(function(e){"use strict";var ge="https://otel.k6n.net",a=null;try{a=typeof document<"u"?document.currentScript||(function(){for(var i=document.getElementsByTagName("script"),d=i.length-1;d>=0;d--)if(i[d].src&&i[d].src.indexOf("otel-rum")!==-1)return i[d];return null})():null}catch{}var V=typeof e.fetch=="function"?e.fetch:null,Ze=typeof e.XMLHttpRequest=="function"?e.XMLHttpRequest:null;function w(i){for(var d="0123456789abcdef",l="",y=0;y<i;y++)l+=d[Math.random()*16|0];return l}function W(){return w(32)}function X(){return w(16)}function Se(i){try{var d=new e.URL(i,e.location.href);return d.origin===e.location.origin}catch{return!1}}function q(i){var d=[];for(var l in i)if(Object.prototype.hasOwnProperty.call(i,l)){var y=i[l];y==null||y===""||d.push({key:l,value:{stringValue:String(y)}})}return d}function Ee(i={}){i=i||{};var d=a&&(a.getAttribute("data-service-name")||a.getAttribute("data-service"))||"",l=a&&a.getAttribute("data-base-url")||"",y=a&&a.getAttribute("data-endpoint")||"",we=a&&a.getAttribute("data-ingest-key")||"",x=(i.baseUrl||l||ge).replace(/\/+$/,""),R=i.endpoint||y;R?!/^https?:\/\//i.test(R)&&!R.startsWith("//")&&(R=x+"/"+R.replace(/^\/+/,"")):R=x+"/v1/logs";var Le=i.vitalsEndpoint||a&&a.getAttribute("data-vitals-endpoint")||x+"/v1/vitals",Te=i.vitalsLibUrl||a&&a.getAttribute("data-vitals-lib")||(a&&a.src?a.src.replace(/[^\/]*$/,"")+"web-vitals.attribution.iife.js":x+"/static/web-vitals.attribution.iife.js");function re(r){if(!r)return!1;var t=String(r);return!!(R&&t.indexOf(R)===0||t.indexOf("/v1/logs")!==-1||t.indexOf("/v1/traces")!==-1||t.indexOf("/v1/metrics")!==-1||t.indexOf("/v1/vitals")!==-1)}var _=i.serviceName||d||"frontend",G=i.environment||a&&a.getAttribute("data-environment")||"",K=i.release||a&&a.getAttribute("data-release")||"",L=i.ingestKey||we||"",H=(i.consent||a&&a.getAttribute("data-consent"))==="granted"?"granted":"denied",be=typeof i.devMode=="boolean"?i.devMode:!!(a&&a.getAttribute("data-dev-mode")==="true"),xe=i.devModeHosts||(a&&a.getAttribute("data-dev-mode-hosts")||"").split(",").map(function(r){return r.trim()}).filter(Boolean),U=!1;try{U=be&&xe.indexOf(new e.URL(e.location.href).hostname)!==-1}catch{}var D=i.minimalDiagnostics,ne=[],P=typeof i.sampleRate=="number"?i.sampleRate:1,Oe=i.maxBreadcrumbs||20,Ae=i.captureConsoleErrors!==!1,Me=i.autoInstrumentFetch!==!1,Ce=i.autoInstrumentXhr!==!1,ke=i.trackSpaRoutes!==!1,z=i.reportHttpFailures!==!1,He=i.captureWebVitals!==!1,Ue=typeof i.captureWebVitalsWithoutConsent=="boolean"?i.captureWebVitalsWithoutConsent:!!(a&&a.getAttribute("data-vitals-without-consent")==="true"),$=!1,De=typeof i.enableTodo=="boolean"?i.enableTodo:!!(a&&a.getAttribute("data-enable-todo")==="true"),Ie=i.excludeTodo===!0||a&&a.getAttribute("data-exclude-todo")==="true",Pe=i.todoLibUrl||a&&a.getAttribute("data-todo-lib")||(a&&a.src?a.src.replace(/[^\/]*$/,"")+"otel-todo.js":x+"/static/otel-todo.js"),Fe=i.injectTraceHeaders!==!1,ie=i.traceHeaderOrigins,Ne=i.maxEventsPerMinute||60,je=i.batchSize||10,Be=i.flushIntervalMs||5e3,O=[],F="",N=T()?w(16):"",A=[],E=null,I=[],Ve=0;function We(r){var t=r==="granted"?"granted":"denied",n=t==="denied"&&U;H===t&&!n||(n&&(U=!1),H=t,A=[],E&&e.clearTimeout(E),E=null,O=[],B={},F="",N=T()?w(16):"",ne.forEach(function(s){s()}),oe())}function ae(){return H}function T(){return H==="granted"||U}function se(){return U?"development":H==="granted"?"consented":D?"minimal":"counts"}function j(){return He&&(T()||Ue)}function oe(){$||!j()||($=!0,Ke())}function Xe(){for(var r=Date.now();I.length&&r-I[0]>6e4;)I.shift();return I.length>=Ne?!1:(I.push(r),!0)}function qe(r){if(A.push(r),A.length>=je){M();return}E||(E=e.setTimeout(M,Be))}function M(){if(E&&(e.clearTimeout(E),E=null),!!A.length){var r=A.splice(0,A.length);_e(r)}}function _e(r){var t={resourceLogs:[{resource:{attributes:q({"service.name":_})},scopeLogs:[{scope:{name:"otel-rum"},logRecords:r}]}]},n=JSON.stringify(t),s=R;L&&(s+=(s.indexOf("?")===-1?"?":"&")+"key="+encodeURIComponent(L));var o=V||(typeof e.fetch=="function"?e.fetch:null);if(o){var c={"Content-Type":"application/json"};L&&(c["X-Otel-Ingest-Key"]=L),o.call(e,s,{method:"POST",mode:"cors",credentials:"omit",referrerPolicy:"no-referrer",headers:c,body:n,keepalive:!0}).catch(function(){});return}}var J={DEBUG:5,INFO:9,WARN:13,ERROR:17,FATAL:21};function Ge(r,t,n,s){if(!T()){var o={source:"frontend","service.release":K,"service.environment":G,"privacy.mode":"counts"},c=n&&n["exception.type"];typeof c=="string"&&["Error","TypeError","ReferenceError","SyntaxError","RangeError","URIError","EvalError","UnhandledRejection","HttpFailure","ResourceLoadError"].indexOf(c)!==-1&&(o["exception.type"]=c);var p=n&&String(n["http.response.status_code"]||"");p&&/^[1-5][0-9]{2}$/.test(p)&&(o["http.response.status_code"]=p);var g="Frontend event";if(D)try{var f=D.sanitize({message:String(t).slice(0,2e3),type:typeof c=="string"?c.slice(0,100):void 0,stacktrace:n&&typeof n["exception.stacktrace"]=="string"?n["exLine truncated
* 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:
* <script src="https://otel.k6n.net/static/otel-rum.js"></script>
* <script>
* var rum = OtelRUM.init({
* serviceName: 'my-web-app',
* baseUrl: 'https://otel.example.com', // optional collector override
* });
*
* // Manual correlation-id injection into any HTTP client:
* fetch('/api/orders', { headers: rum.traceHeaders() });
*
* // Manual error reporting:
* try { risky(); } catch (err) { rum.captureException(err); }
* </script>
*
* 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 <script> element
try {
if (typeof document !== 'undefined' && scriptEl && (scriptEl.getAttribute('data-auto-init') === 'true' || scriptEl.getAttribute('data-service-name') || scriptEl.getAttribute('data-service'))) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
global.OtelRUM.init();
});
} else {
global.OtelRUM.init();
}
}
} catch (e) {}
})(typeof window !== 'undefined' ? window : this);
+1 -327
View File
@@ -1,327 +1 @@
/** "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='<form method="dialog"><div class="meta"></div><button type="button" class="otel-todo-pick" aria-pressed="false">\u2316 Reference an element</button><div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">\u2715</button></div><textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea><select name="component" aria-label="Component (optional)"></select><div class="row"><span class="status" role="status"></span><button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div></form>';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.textCoLine truncated
* otel-todo.js - file todos / issues straight from the page.
*
* Shows a small floating symbol and listens for a keyboard shortcut. Opens a
* native <dialog> in the bottom-right corner with a text box, an optional
* component picker, and an "Reference an element" toggle that lets you click
* any node on the page (with a hover outline) to attach its CSS selector and
* a short label to the note. POSTs the note (with the current path, hash and
* any picked element) to the collector's /v1/todos endpoint.
*
* <script src="https://otel.example.com/static/otel-todo.js"
* data-service-name="checkout-web"
* data-ingest-key="..."
* data-components="Header,Cart,Payment"></script>
*
* 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 =
'<form method="dialog">' +
'<div class="meta"></div>' +
'<button type="button" class="otel-todo-pick" aria-pressed="false">⌖ Reference an element</button>' +
'<div class="otel-todo-ref"><span></span><button type="button" data-clear-ref aria-label="Remove reference">✕</button></div>' +
'<textarea name="text" placeholder="What needs doing?" required maxlength="4000"></textarea>' +
'<select name="component" aria-label="Component (optional)"></select>' +
'<div class="row"><span class="status" role="status"></span>' +
'<button type="button" data-cancel>Cancel</button><button type="submit">Add</button></div>' +
'</form>';
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);