From ef94c8b64505610bf8bebb44ea0252dad3972d8d Mon Sep 17 00:00:00 2001 From: Abinet Debele Date: Tue, 25 Nov 2025 11:58:06 -0800 Subject: [PATCH 1/3] rebase and add all changes for browser navigation instrumentation --- .github/component-label-map.yml | 4 + .github/component_owners.yml | 4 +- .github/workflows/lint.yml | 2 + .release-please-manifest.json | 3 +- examples/web/.babelrc | 12 + examples/web/DEPENDENCIES.md | 7 + .../web/examples/document-load/index.html | 38 +- examples/web/examples/document-load/index.js | 4 +- examples/web/examples/index.html | 45 ++ examples/web/examples/meta/index.html | 48 +- examples/web/examples/meta/index.js | 4 +- examples/web/examples/navigation/index.html | 163 ++++ examples/web/examples/navigation/index.js | 345 ++++++++ examples/web/examples/react-spa/index.html | 139 ++++ examples/web/examples/react-spa/index.jsx | 69 ++ examples/web/examples/react-spa/src/App.css | 306 ++++++++ examples/web/examples/react-spa/src/App.jsx | 37 + .../examples/react-spa/src/pages/About.jsx | 38 + .../examples/react-spa/src/pages/Contact.jsx | 79 ++ .../web/examples/react-spa/src/pages/Home.jsx | 47 ++ .../examples/react-spa/src/pages/Products.jsx | 0 .../web/examples/user-interaction/index.html | 48 +- .../web/examples/user-interaction/index.js | 4 +- examples/web/package.json | 51 +- examples/web/webpack.config.js | 49 +- karma.webpack.js | 10 +- package-lock.json | 119 ++- .../LICENSE | 201 +++++ .../README.md | 126 +++ .../karma.conf.js | 27 + .../package.json | 62 ++ .../src/index.ts | 18 + .../src/instrumentation.ts | 383 +++++++++ .../src/types.ts | 39 + .../src/utils.ts | 118 +++ .../test/index-webpack.ts | 20 + .../test/navigation.test.ts | 734 ++++++++++++++++++ .../test/utils.test.ts | 181 +++++ .../tsconfig.esm.json | 11 + .../tsconfig.esnext.json | 11 + .../tsconfig.json | 11 + .../web-test-runner.config.mjs | 43 + release-please-config.json | 3 +- 43 files changed, 3546 insertions(+), 117 deletions(-) create mode 100644 examples/web/.babelrc create mode 100644 examples/web/DEPENDENCIES.md create mode 100644 examples/web/examples/index.html create mode 100644 examples/web/examples/navigation/index.html create mode 100644 examples/web/examples/navigation/index.js create mode 100644 examples/web/examples/react-spa/index.html create mode 100644 examples/web/examples/react-spa/index.jsx create mode 100644 examples/web/examples/react-spa/src/App.css create mode 100644 examples/web/examples/react-spa/src/App.jsx create mode 100644 examples/web/examples/react-spa/src/pages/About.jsx create mode 100644 examples/web/examples/react-spa/src/pages/Contact.jsx create mode 100644 examples/web/examples/react-spa/src/pages/Home.jsx create mode 100644 examples/web/examples/react-spa/src/pages/Products.jsx create mode 100644 packages/instrumentation-browser-navigation/LICENSE create mode 100644 packages/instrumentation-browser-navigation/README.md create mode 100644 packages/instrumentation-browser-navigation/karma.conf.js create mode 100644 packages/instrumentation-browser-navigation/package.json create mode 100644 packages/instrumentation-browser-navigation/src/index.ts create mode 100644 packages/instrumentation-browser-navigation/src/instrumentation.ts create mode 100644 packages/instrumentation-browser-navigation/src/types.ts create mode 100644 packages/instrumentation-browser-navigation/src/utils.ts create mode 100644 packages/instrumentation-browser-navigation/test/index-webpack.ts create mode 100644 packages/instrumentation-browser-navigation/test/navigation.test.ts create mode 100644 packages/instrumentation-browser-navigation/test/utils.test.ts create mode 100644 packages/instrumentation-browser-navigation/tsconfig.esm.json create mode 100644 packages/instrumentation-browser-navigation/tsconfig.esnext.json create mode 100644 packages/instrumentation-browser-navigation/tsconfig.json create mode 100644 packages/instrumentation-browser-navigation/web-test-runner.config.mjs diff --git a/.github/component-label-map.yml b/.github/component-label-map.yml index ee920923ff..0fd1ed4159 100644 --- a/.github/component-label-map.yml +++ b/.github/component-label-map.yml @@ -291,6 +291,10 @@ pkg:propagator-aws-xray-lambda: - changed-files: - any-glob-to-any-file: - packages/propagator-aws-xray-lambda/** +pkg:instrumentation-browser-navigation: + - changed-files: + - any-glob-to-any-file: + - packages/instrumentation-browser-navigation/** pkg-status:unmaintained: - changed-files: - any-glob-to-any-file: diff --git a/.github/component_owners.yml b/.github/component_owners.yml index a1f6c7d532..5f01a17b2c 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -166,6 +166,9 @@ components: - wolfgangcodes packages/plugin-react-load: - martinkuba + packages/instrumentation-browser-navigation: + - Abinet18 + - martinkuba packages/propagator-instana: - kirrg001 packages/propagator-ot-trace: [] @@ -174,6 +177,5 @@ components: - jj22ee packages/propagator-aws-xray-lambda: [ ] # Unmaintained - ignored-authors: - renovate-bot diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c9bd84705b..bba9e79c0f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,6 +19,8 @@ jobs: node-version: 18 cache: npm - run: npm ci --ignore-scripts + - name: Compile + run: npm run compile - name: Lint run: | npm run lint diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4979cea7fe..412196a83e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -68,5 +68,6 @@ "packages/propagator-instana": "0.4.3", "packages/propagator-ot-trace": "0.28.3", "packages/propagator-aws-xray": "2.1.4", - "packages/propagator-aws-xray-lambda": "0.55.4" + "packages/propagator-aws-xray-lambda": "0.55.4", + "packages/instrumentation-browser-navigation": "0.54.0" } diff --git a/examples/web/.babelrc b/examples/web/.babelrc new file mode 100644 index 0000000000..2858ff31c8 --- /dev/null +++ b/examples/web/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + ["@babel/preset-env", { + "targets": { + "browsers": ["last 2 versions"] + } + }], + ["@babel/preset-react", { + "runtime": "automatic" + }] + ] +} diff --git a/examples/web/DEPENDENCIES.md b/examples/web/DEPENDENCIES.md new file mode 100644 index 0000000000..9da201691c --- /dev/null +++ b/examples/web/DEPENDENCIES.md @@ -0,0 +1,7 @@ +# Dependencies Notes + +## Local Development Dependencies + +- `@opentelemetry/instrumentation-browser-navigation`: Currently using `file:../../packages/instrumentation-browser-navigation` + - **TODO**: Change to npm version (e.g., `"^0.54.0"`) when package is published to npm registry + - This is a temporary local file reference for development and testing diff --git a/examples/web/examples/document-load/index.html b/examples/web/examples/document-load/index.html index b0362bc449..fb28d502bd 100644 --- a/examples/web/examples/document-load/index.html +++ b/examples/web/examples/document-load/index.html @@ -1,12 +1,11 @@ + + + Document Load Plugin Example + - - - Document Load Plugin Example - - - - - - - + - - Example of using Web Tracer with document load plugin with console exporter and collector exporter - -
- + + - + + Example of using Web Tracer with document load plugin with console exporter + and collector exporter + +
+ + +
+ +
+ diff --git a/examples/web/examples/document-load/index.js b/examples/web/examples/document-load/index.js index ecd5c28cff..b0d66086e9 100644 --- a/examples/web/examples/document-load/index.js +++ b/examples/web/examples/document-load/index.js @@ -32,11 +32,11 @@ import { W3CTraceContextPropagator, } from '@opentelemetry/core'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; -import { Resource } from '@opentelemetry/resources'; +import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; const provider = new WebTracerProvider({ - resource: new Resource({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'web-service-dl', }), spanProcessors: [ diff --git a/examples/web/examples/index.html b/examples/web/examples/index.html new file mode 100644 index 0000000000..3692efaf8d --- /dev/null +++ b/examples/web/examples/index.html @@ -0,0 +1,45 @@ + + + + + + OpenTelemetry Web Examples + + + +

OpenTelemetry Web Examples

+

Choose an example to explore:

+ + + diff --git a/examples/web/examples/meta/index.html b/examples/web/examples/meta/index.html index 6862dcc0d7..0285c9da31 100644 --- a/examples/web/examples/meta/index.html +++ b/examples/web/examples/meta/index.html @@ -1,12 +1,11 @@ + + + User Interaction Example + - - - User Interaction Example - - - - - - - - - - Example of using Web Tracer with meta package and with console exporter and collector exporter - -
- -
-
-
-
-
-
-
-
+ - + + + + Example of using Web Tracer with meta package and with console exporter and + collector exporter + +
+ +
+
+
+
+
+
+
+
+ diff --git a/examples/web/examples/meta/index.js b/examples/web/examples/meta/index.js index 2789e29050..5dd73af698 100644 --- a/examples/web/examples/meta/index.js +++ b/examples/web/examples/meta/index.js @@ -26,11 +26,11 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { B3Propagator } from '@opentelemetry/propagator-b3'; import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; -import { Resource } from '@opentelemetry/resources'; +import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; const providerWithZone = new WebTracerProvider({ - resource: new Resource({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'web-service-meta', }), spanProcessors: [ diff --git a/examples/web/examples/navigation/index.html b/examples/web/examples/navigation/index.html new file mode 100644 index 0000000000..cce7ac9274 --- /dev/null +++ b/examples/web/examples/navigation/index.html @@ -0,0 +1,163 @@ + + + + + Browser Navigation Instrumentation Example + + + + + +

🧭 Browser Navigation Instrumentation Example

+

+ This example demonstrates the + Browser Navigation Instrumentation package with enhanced + Navigation API support, custom log record data, and comprehensive event + tracking. +

+ +
+

📊 Features Demonstrated:

+ +
+ + + + +
+ +
+ +
+ +
+ + + + + + diff --git a/examples/web/examples/navigation/index.js b/examples/web/examples/navigation/index.js new file mode 100644 index 0000000000..522076d971 --- /dev/null +++ b/examples/web/examples/navigation/index.js @@ -0,0 +1,345 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { logs } from '@opentelemetry/api-logs'; +import { + LoggerProvider, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, +} from '@opentelemetry/sdk-logs'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { BrowserNavigationInstrumentation } from '@opentelemetry/instrumentation-browser-navigation'; + +const loggerProvider = new LoggerProvider({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'navigation-example-app', + }), +}); + +loggerProvider.addLogRecordProcessor( + new SimpleLogRecordProcessor(new ConsoleLogRecordExporter()) +); +loggerProvider.addLogRecordProcessor( + new SimpleLogRecordProcessor(new OTLPLogExporter()) +); +logs.setGlobalLoggerProvider(loggerProvider); + +registerInstrumentations({ + instrumentations: [ + new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: true, + applyCustomLogRecordData: logRecord => { + if (!logRecord.attributes) { + logRecord.attributes = {}; + } + // Add custom attributes to navigation events + logRecord.attributes['app.feature'] = 'navigation-tracking'; + }, + }), + ], +}); + +// Define routes and their content +const routes = { + '/navigation/route1': + '

Welcome to Route 1

This is the content for Route 1.

', + '/navigation/route2': + '

Welcome to Route 2

This is the content for Route 2.

', +}; + +// Function to navigate to a route +function navigateTo(url) { + console.log('Navigating to', url); + history.pushState(null, null, url); + handleRouteChange(); +} + +// Function to handle the route change +function handleRouteChange() { + const path = window.location.pathname; // Get current path + const routeContent = + routes[path] || + '

Navigation Example

Use the buttons above to test different navigation events.

'; + document.getElementById('content').innerHTML = routeContent; +} + +// Attach event listeners to navigation links + +const attachEventListenersToLinks = () => { + document.querySelectorAll('a[data-link]').forEach(link => { + console.log('attach event listener to link', link.href); + link.addEventListener('click', event => { + console.log('Link clicked', event.target.href); + event.preventDefault(); + navigateTo(event.target.href); + }); + }); +}; + +// Hash change functionality +let hashCounter = 0; +function testHashChange() { + hashCounter++; + const newHash = `section-${hashCounter}`; + location.hash = newHash; + + document.getElementById('hash-content').innerHTML = ``; +} + +// Back button functionality +function goBack() { + if (window.history.length > 1) { + window.history.back(); + + // Update content to show back navigation happened + setTimeout(() => { + const contentDiv = document.getElementById('content') || document.body; + const backIndicator = document.createElement('div'); + backIndicator.className = 'nav-result'; + // Create elements safely to avoid XSS + const resultDiv = document.createElement('div'); + resultDiv.className = 'nav-result'; + + const title = document.createElement('h4'); + title.textContent = '⬅️ Back Navigation'; + + const method = document.createElement('p'); + method.innerHTML = 'Method: history.back()'; + + const urlPara = document.createElement('p'); + urlPara.innerHTML = 'Current URL: '; + urlPara.querySelector('code').textContent = location.href; // Safe text assignment + + const expectedTitle = document.createElement('p'); + expectedTitle.innerHTML = 'Expected Instrumentation:'; + + const list = document.createElement('ul'); + list.innerHTML = ` +
  • ✓ same_document: true
  • +
  • ✓ hash_change: depends on URL change
  • +
  • ✓ type: traverse
  • + `; + + const consoleNote = document.createElement('p'); + consoleNote.className = 'console-note'; + consoleNote.textContent = '📊 Check console for navigation events!'; + + resultDiv.appendChild(title); + resultDiv.appendChild(method); + resultDiv.appendChild(urlPara); + resultDiv.appendChild(expectedTitle); + resultDiv.appendChild(list); + resultDiv.appendChild(consoleNote); + + backIndicator.appendChild(resultDiv); + contentDiv.appendChild(backIndicator); + + // Remove the indicator after 5 seconds + setTimeout(() => backIndicator.remove(), 5000); + }, 100); + } else { + alert('No history to go back to!'); + } +} + +// Navigation API navigate functionality +let navApiCounter = 0; + +function testNavigationApiHash() { + navApiCounter++; + // Only change the hash part + const url = new URL(window.location); + url.hash = `nav-api-section-${navApiCounter}`; + testNavigationApiRoute(url.href, 'Hash Navigation'); +} + +function testNavigationApiRoute(route, navigationType) { + if ('navigation' in window) { + try { + // Log current navigation.activation.navigationType before navigation + console.log('🧭 Before Navigation API navigate():', { + currentNavigationType: window.navigation.activation?.navigationType, + targetRoute: route, + method: navigationType, + }); + + // Use Navigation API navigate method + window.navigation.navigate(route); + + // Log navigation.activation.navigationType after navigation + setTimeout(() => { + console.log('🧭 After Navigation API navigate():', { + newNavigationType: window.navigation.activation?.navigationType, + currentUrl: location.href, + expectedInstrumentation: { + 'browser.navigation.type': + window.navigation.activation?.navigationType || 'push', + }, + }); + }, 50); + + // Update content after navigation + setTimeout(() => { + const contentElement = document.getElementById('nav-api-content'); + + // Create elements safely to avoid XSS + const resultDiv = document.createElement('div'); + resultDiv.className = 'nav-result'; + + const title = document.createElement('h4'); + title.textContent = `✅ ${navigationType} Completed`; + + const targetPara = document.createElement('p'); + targetPara.innerHTML = 'Target: '; + targetPara.querySelector('code').textContent = route; // Safe text assignment + + const urlPara = document.createElement('p'); + urlPara.innerHTML = 'Current URL: '; + urlPara.querySelector('code').textContent = window.location.href; // Safe text assignment + + const apiPara = document.createElement('p'); + apiPara.innerHTML = 'Navigation API: Supported ✓'; + + const button = document.createElement('button'); + button.id = 'navApiHashBtn'; + button.style.display = 'none'; + button.textContent = 'Nav API: Hash'; + + const consoleNote = document.createElement('p'); + consoleNote.className = 'console-note'; + consoleNote.textContent = + '📊 Check console for detailed navigation events and instrumentation data!'; + + resultDiv.appendChild(title); + resultDiv.appendChild(targetPara); + resultDiv.appendChild(urlPara); + resultDiv.appendChild(apiPara); + resultDiv.appendChild(button); + resultDiv.appendChild(consoleNote); + + contentElement.innerHTML = ''; // Clear existing content + contentElement.appendChild(resultDiv); + }, 100); + } catch (error) { + console.error(`❌ Navigation API error for ${navigationType}:`, error); + // Fallback to history API + const fallbackRoute = route.startsWith('#') + ? route + : `?fallback=${Date.now()}`; + history.pushState({}, '', fallbackRoute); + + // Create error content safely + const contentElement = document.getElementById('nav-api-content'); + const errorDiv = document.createElement('div'); + errorDiv.className = 'nav-result error'; + + const title = document.createElement('h4'); + title.textContent = '⚠️ Navigation API Failed'; + + const fallbackPara = document.createElement('p'); + fallbackPara.innerHTML = 'Fallback used: '; + fallbackPara.querySelector('code').textContent = fallbackRoute; + + const errorPara = document.createElement('p'); + errorPara.innerHTML = 'Error: '; + const errorSpan = document.createElement('span'); + errorSpan.textContent = error.message; + errorPara.appendChild(errorSpan); + + errorDiv.appendChild(title); + errorDiv.appendChild(fallbackPara); + errorDiv.appendChild(errorPara); + + contentElement.innerHTML = ''; + contentElement.appendChild(errorDiv); + } + } else { + // Fallback for browsers without Navigation API + const fallbackRoute = route.startsWith('#') + ? route + : `?fallback=${Date.now()}`; + history.pushState({}, '', fallbackRoute); + + // Create fallback content safely + const contentElement = document.getElementById('nav-api-content'); + const fallbackDiv = document.createElement('div'); + fallbackDiv.className = 'nav-result fallback'; + + const title = document.createElement('h4'); + title.textContent = '📱 Navigation API Not Available'; + + const fallbackPara = document.createElement('p'); + fallbackPara.innerHTML = 'Fallback used: '; + fallbackPara.querySelector('code').textContent = fallbackRoute; + + const methodPara = document.createElement('p'); + methodPara.innerHTML = 'Method: history.pushState()'; + + const consoleNote = document.createElement('p'); + consoleNote.className = 'console-note'; + consoleNote.textContent = '📊 Check console for instrumentation data!'; + + fallbackDiv.appendChild(title); + fallbackDiv.appendChild(fallbackPara); + fallbackDiv.appendChild(methodPara); + fallbackDiv.appendChild(consoleNote); + + contentElement.innerHTML = ''; + contentElement.appendChild(fallbackDiv); + } +} + +window.addEventListener('popstate', handleRouteChange); + +// Add event listeners when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + attachEventListenersToLinks(); + + // Hash change button + document + .getElementById('hashChangeBtn') + .addEventListener('click', testHashChange); + + // Back button + document.getElementById('backBtn').addEventListener('click', goBack); + + // Navigation API hash button - only show if Navigation API is available + const navApiHashBtn = document.getElementById('navApiHashBtn'); + if ('navigation' in window) { + navApiHashBtn.addEventListener('click', testNavigationApiHash); + } else { + navApiHashBtn.style.display = 'none'; + } +}); + +const loadTimeSetup = () => { + handleRouteChange(); +}; +window.addEventListener('load', loadTimeSetup); diff --git a/examples/web/examples/react-spa/index.html b/examples/web/examples/react-spa/index.html new file mode 100644 index 0000000000..1fa82aa236 --- /dev/null +++ b/examples/web/examples/react-spa/index.html @@ -0,0 +1,139 @@ + + + + + + React SPA - OpenTelemetry Navigation Demo + + + + +
    + + +
    +
    +
    Loading React SPA Navigation Demo...
    +
    + + + + + + + diff --git a/examples/web/examples/react-spa/index.jsx b/examples/web/examples/react-spa/index.jsx new file mode 100644 index 0000000000..d208404908 --- /dev/null +++ b/examples/web/examples/react-spa/index.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { logs } from '@opentelemetry/api-logs'; +import { + LoggerProvider, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, +} from '@opentelemetry/sdk-logs'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { BrowserNavigationInstrumentation } from '@opentelemetry/instrumentation-browser-navigation'; +import App from './src/App'; + +// Initialize OpenTelemetry logging +const loggerProvider = new LoggerProvider({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'react-spa-navigation-demo', + }), +}); + +// Add console exporter for development +loggerProvider.addLogRecordProcessor( + new SimpleLogRecordProcessor(new ConsoleLogRecordExporter()) +); + +// Add OTLP HTTP exporter to send logs to server +loggerProvider.addLogRecordProcessor( + new SimpleLogRecordProcessor(new OTLPLogExporter({ + url: 'http://localhost:4318/v1/logs', // Standard OTLP endpoint + headers: { + 'Content-Type': 'application/json', + }, + })) +); + +// Set the global logger provider +logs.setGlobalLoggerProvider(loggerProvider); + +// Register the browser navigation instrumentation +registerInstrumentations({ + instrumentations: [ + new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: true, // Re-enable Navigation API + applyCustomLogRecordData: logRecord => { + if (!logRecord.attributes) { + logRecord.attributes = {}; + } + // Add custom attributes to navigation events + logRecord.attributes['app.type'] = 'react-spa'; + logRecord.attributes['app.framework'] = 'react'; + logRecord.attributes['app.version'] = '1.0.0'; + + // Add timestamp for better tracking + logRecord.attributes['navigation.timestamp'] = Date.now(); + }, + }), + ], +}); + +// Create React root and render the app +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/examples/web/examples/react-spa/src/App.css b/examples/web/examples/react-spa/src/App.css new file mode 100644 index 0000000000..25417c0a75 --- /dev/null +++ b/examples/web/examples/react-spa/src/App.css @@ -0,0 +1,306 @@ +/* React SPA Styles */ +.app { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + line-height: 1.6; + color: #333; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1rem 2rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.app-header h1 { + margin: 0 0 1rem 0; + font-size: 1.8rem; +} + +.nav { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.nav-link { + color: white; + text-decoration: none; + padding: 0.5rem 1rem; + border-radius: 4px; + transition: background-color 0.2s; + border: 1px solid rgba(255,255,255,0.2); +} + +.nav-link:hover { + background-color: rgba(255,255,255,0.1); + text-decoration: none; +} + +.main-content { + flex: 1; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; +} + +.page { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.page h2 { + color: #2c3e50; + margin-bottom: 1rem; + font-size: 2rem; +} + +.test-buttons { + margin: 1.5rem 0; + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.test-btn { + background: #3498db; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; +} + +.test-btn:hover { + background: #2980b9; +} + +.content-section { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 8px; + margin: 1.5rem 0; + border-left: 4px solid #3498db; +} + +.content-section h3 { + margin-top: 0; + color: #2c3e50; +} + +.content-section ul { + margin: 0.5rem 0; + padding-left: 1.5rem; +} + +.content-section li { + margin: 0.25rem 0; +} + +.hash-section { + background: #e8f5e8; + padding: 1rem; + border-radius: 4px; + margin: 1rem 0; + border-left: 4px solid #27ae60; +} + +.hash-section h4 { + margin-top: 0; + color: #27ae60; +} + +/* Products page styles */ +.products-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin: 2rem 0; +} + +.product-card { + background: white; + border: 2px solid #e1e8ed; + border-radius: 8px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.product-card:hover { + border-color: #3498db; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.product-card.selected { + border-color: #27ae60; + background: #f0f8f0; +} + +.product-card h3 { + margin-top: 0; + color: #2c3e50; +} + +.product-details { + background: #e8f5e8; + padding: 1.5rem; + border-radius: 8px; + margin: 2rem 0; + border-left: 4px solid #27ae60; +} + +.cta-button { + display: inline-block; + background: #27ae60; + color: white; + text-decoration: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + margin-top: 1rem; + transition: background-color 0.2s; +} + +.cta-button:hover { + background: #229954; + text-decoration: none; +} + +.navigation-test { + background: #fff3cd; + padding: 1.5rem; + border-radius: 8px; + margin: 2rem 0; + border-left: 4px solid #ffc107; +} + +.navigation-test h3 { + margin-top: 0; + color: #856404; +} + +/* Contact page styles */ +.contact-form { + background: white; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin: 2rem 0; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: #2c3e50; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 2px solid #e1e8ed; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #3498db; +} + +.submit-btn { + background: #27ae60; + color: white; + border: none; + padding: 1rem 2rem; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s; +} + +.submit-btn:hover { + background: #229954; +} + +.contact-info { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 8px; + margin: 2rem 0; +} + +.contact-info h3 { + margin-top: 0; + color: #2c3e50; +} + +.contact-info a { + color: #3498db; + text-decoration: none; +} + +.contact-info a:hover { + text-decoration: underline; +} + +.footer { + background: #2c3e50; + color: white; + text-align: center; + padding: 1rem; + margin-top: auto; +} + +.footer p { + margin: 0; +} + +/* Responsive design */ +@media (max-width: 768px) { + .main-content { + padding: 1rem; + } + + .app-header { + padding: 1rem; + } + + .nav { + gap: 0.5rem; + } + + .nav-link { + padding: 0.4rem 0.8rem; + font-size: 0.9rem; + } + + .test-buttons { + flex-direction: column; + } + + .products-grid { + grid-template-columns: 1fr; + } +} diff --git a/examples/web/examples/react-spa/src/App.jsx b/examples/web/examples/react-spa/src/App.jsx new file mode 100644 index 0000000000..5aeafd42c6 --- /dev/null +++ b/examples/web/examples/react-spa/src/App.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import Home from './pages/Home'; +import About from './pages/About'; +import Contact from './pages/Contact'; +import './App.css'; + +function App() { + return ( + +
    +
    +

    React SPA Navigation Test

    + +
    + +
    + + } /> + } /> + } /> + +
    + +
    +

    OpenTelemetry Browser Navigation Instrumentation Demo

    +
    +
    +
    + ); +} + +export default App; diff --git a/examples/web/examples/react-spa/src/pages/About.jsx b/examples/web/examples/react-spa/src/pages/About.jsx new file mode 100644 index 0000000000..ad4a4c2757 --- /dev/null +++ b/examples/web/examples/react-spa/src/pages/About.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +function About() { + const handleHashNavigation = () => { + window.location.hash = '#team'; + }; + + return ( +
    +

    About Us

    +

    Learn more about our OpenTelemetry browser navigation instrumentation.

    + +
    + +
    + +
    +

    About This Demo

    +

    This React application demonstrates:

    +
      +
    • Single Page Application (SPA) routing
    • +
    • Navigation event tracking
    • +
    • Hash change detection
    • +
    • Browser history management
    • +
    +
    + +
    +

    Our Team

    +

    We're building better observability tools for web applications.

    +
    +
    + ); +} + +export default About; diff --git a/examples/web/examples/react-spa/src/pages/Contact.jsx b/examples/web/examples/react-spa/src/pages/Contact.jsx new file mode 100644 index 0000000000..b6cea5a24a --- /dev/null +++ b/examples/web/examples/react-spa/src/pages/Contact.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +function Contact() { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ name: '', email: '', message: '' }); + + const handleSubmit = (e) => { + e.preventDefault(); + console.log('Form submitted:', formData); + // Simulate form submission and navigation + alert('Thank you for your message!'); + navigate('/'); + }; + + const handleInputChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }); + }; + + return ( +
    +

    Contact Us

    +

    Get in touch with our team about OpenTelemetry instrumentation.

    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + + +
    + +
    +

    Other Ways to Reach Us

    +

    Email: support@opentelemetry.io

    +

    GitHub: opentelemetry-js-contrib

    +
    +
    + ); +} + +export default Contact; diff --git a/examples/web/examples/react-spa/src/pages/Home.jsx b/examples/web/examples/react-spa/src/pages/Home.jsx new file mode 100644 index 0000000000..7732d1421f --- /dev/null +++ b/examples/web/examples/react-spa/src/pages/Home.jsx @@ -0,0 +1,47 @@ +import React from 'react'; + +function Home() { + const handleHashChange = () => { + window.location.hash = '#section1'; + }; + + const handleProgrammaticNavigation = () => { + window.history.pushState({}, '', '/react-spa/about'); + // Trigger a popstate event to simulate navigation + window.dispatchEvent(new PopStateEvent('popstate')); + }; + + return ( +
    +

    Home Page

    +

    Welcome to the React SPA navigation test application!

    + +
    + + +
    + +
    +

    Navigation Testing

    +

    This app tests various navigation scenarios:

    +
      +
    • React Router Link navigation (SPA routing)
    • +
    • Hash-based navigation
    • +
    • Programmatic navigation
    • +
    • Browser back/forward buttons
    • +
    +
    + +
    +

    Hash Section 1

    +

    This section is reached via hash navigation.

    +
    +
    + ); +} + +export default Home; diff --git a/examples/web/examples/react-spa/src/pages/Products.jsx b/examples/web/examples/react-spa/src/pages/Products.jsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/web/examples/user-interaction/index.html b/examples/web/examples/user-interaction/index.html index 958fe75efc..1f4a54aab5 100644 --- a/examples/web/examples/user-interaction/index.html +++ b/examples/web/examples/user-interaction/index.html @@ -1,12 +1,11 @@ + + + User Interaction Example + - - - User Interaction Example - - - - - - - - - - Example of using Web Tracer with UserInteractionInstrumentation and XMLHttpRequestInstrumentation with console exporter and collector exporter - -
    - -
    -
    -
    -
    -
    -
    -
    -
    + - + + + + Example of using Web Tracer with UserInteractionInstrumentation and + XMLHttpRequestInstrumentation with console exporter and collector exporter + +
    + +
    +
    +
    +
    +
    +
    +
    +
    + diff --git a/examples/web/examples/user-interaction/index.js b/examples/web/examples/user-interaction/index.js index e2fb45fa33..ce49e1d824 100644 --- a/examples/web/examples/user-interaction/index.js +++ b/examples/web/examples/user-interaction/index.js @@ -27,11 +27,11 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { B3Propagator } from '@opentelemetry/propagator-b3'; import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; -import { Resource } from '@opentelemetry/resources'; +import { resourceFromAttributes } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; const providerWithZone = new WebTracerProvider({ - resource: new Resource({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'web-service-ui', }), spanProcessors: [ diff --git a/examples/web/package.json b/examples/web/package.json index c6d4fc0f22..373be231a6 100644 --- a/examples/web/package.json +++ b/examples/web/package.json @@ -5,9 +5,9 @@ "description": "Example of using web plugins in browser", "main": "index.js", "scripts": { - "docker:start": "cd ./docker && docker compose down && docker compose up", - "docker:startd": "cd ./docker && docker compose down && docker compose up -d", - "start": "webpack-dev-server --progress --color --port 8090 --config ./webpack.config.js --hot --host 0.0.0.0" + "docker:start": "cd ./docker && docker-compose down && docker-compose up", + "docker:startd": "cd ./docker && docker-compose down && docker-compose up -d", + "start": "webpack serve --mode development --progress --port 8090 --config webpack.config.js --hot --host 0.0.0.0" }, "repository": { "type": "git", @@ -28,28 +28,39 @@ }, "devDependencies": { "@babel/core": "^7.21.8", + "@babel/preset-env": "^7.21.5", + "@babel/preset-react": "^7.18.6", "babel-loader": "^8.3.0", + "css-loader": "^6.8.1", + "style-loader": "^3.3.3", "ts-loader": "^6.2.2", - "webpack": "5.89.0", - "webpack-cli": "^5.0.0", - "webpack-dev-server": "^4.0.0", - "webpack-merge": "^4.2.2" + "webpack": "^5.93.0", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.2", + "webpack-merge": "^6.0.1" }, "dependencies": { "@opentelemetry/api": "^1.4.1", - "@opentelemetry/auto-instrumentations-web": "^0.32.2", - "@opentelemetry/context-zone": "^1.13.0", - "@opentelemetry/core": "^1.13.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.39.1", - "@opentelemetry/instrumentation": "^0.39.1", - "@opentelemetry/instrumentation-document-load": "^0.32.2", - "@opentelemetry/instrumentation-user-interaction": "^0.32.3", - "@opentelemetry/instrumentation-xml-http-request": "^0.39.1", - "@opentelemetry/propagator-b3": "^1.13.0", - "@opentelemetry/resources": "^1.13.0", - "@opentelemetry/sdk-trace-base": "^1.13.0", - "@opentelemetry/sdk-trace-web": "^1.13.0", - "@opentelemetry/semantic-conventions": "^1.27.0" + "@opentelemetry/api-logs": "^0.53.0", + "@opentelemetry/auto-instrumentations-web": "^0.53.0", + "@opentelemetry/context-zone": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.53.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.207.0", + "@opentelemetry/instrumentation": "^0.207.0", + "@opentelemetry/instrumentation-user-interaction": "^0.52.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.207.0", + "@opentelemetry/instrumentation-document-load": "^0.53.0", + "@opentelemetry/instrumentation-browser-navigation": "file:../../packages/instrumentation-browser-navigation", + "@opentelemetry/propagator-b3": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-logs": "^0.53.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0" }, "homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib#readme" } diff --git a/examples/web/webpack.config.js b/examples/web/webpack.config.js index dbdb550f6f..e32bcb5517 100644 --- a/examples/web/webpack.config.js +++ b/examples/web/webpack.config.js @@ -17,7 +17,7 @@ 'use strict'; const webpack = require('webpack'); -const webpackMerge = require('webpack-merge'); +const { merge } = require('webpack-merge'); const path = require('path'); const directory = path.resolve(__dirname); @@ -25,9 +25,14 @@ const directory = path.resolve(__dirname); const common = { mode: 'development', entry: { - 'document-load': 'examples/document-load/index.js', - meta: 'examples/meta/index.js', - 'user-interaction': 'examples/user-interaction/index.js', + 'document-load': path.resolve(__dirname, 'examples/document-load/index.js'), + meta: path.resolve(__dirname, 'examples/meta/index.js'), + 'user-interaction': path.resolve( + __dirname, + 'examples/user-interaction/index.js' + ), + navigation: path.resolve(__dirname, 'examples/navigation/index.js'), + 'react-spa': path.resolve(__dirname, 'examples/react-spa/index.jsx'), }, output: { path: path.resolve(__dirname, 'dist'), @@ -38,19 +43,23 @@ const common = { module: { rules: [ { - test: /\.js[x]?$/, - exclude: /(node_modules)/, + test: /\.jsx?$/, + exclude: /node_modules/, use: { loader: 'babel-loader', }, }, { test: /\.ts$/, - exclude: /(node_modules)/, + exclude: /node_modules/, use: { loader: 'ts-loader', }, }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, ], }, resolve: { @@ -59,14 +68,34 @@ const common = { }, }; -module.exports = webpackMerge(common, { +const devConfig = { devtool: 'eval-source-map', devServer: { - static: path.resolve(path.join(__dirname, 'examples')), + static: [ + { + directory: path.resolve(__dirname, 'examples'), + }, + { + directory: path.resolve(__dirname, 'dist'), + publicPath: '/', + }, + ], + compress: true, + port: 8090, + hot: true, + host: '0.0.0.0', + historyApiFallback: { + rewrites: [ + { from: /^\/navigation/, to: '/navigation/index.html' }, + { from: /^\/react-spa/, to: '/react-spa/index.html' }, + ], + }, }, plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('development'), }), ], -}); +}; + +module.exports = merge(common, devConfig); diff --git a/karma.webpack.js b/karma.webpack.js index 3ab9f82ad0..93cc8b8d11 100644 --- a/karma.webpack.js +++ b/karma.webpack.js @@ -23,10 +23,16 @@ module.exports = { output: { filename: 'bundle.js' }, resolve: { extensions: ['.ts', '.js', '.tsx'], + alias: { + // Some ESM packages (e.g., sinon-esm) import 'process/browser' directly and require full path resolution + 'process/browser': require.resolve('process/browser'), + }, fallback: { // Enable the assert library polyfill because that is used in tests - assert: require.resolve('assert/'), - util: require.resolve('util/'), + "assert": require.resolve('assert/'), + "util": require.resolve('util/'), + // Polyfill Node's process for browser bundles + "process": require.resolve('process/browser'), }, }, devtool: 'eval-source-map', diff --git a/package-lock.json b/package-lock.json index a353b7bd86..4901dc0425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,43 @@ "webpack-merge": "6.0.1" } }, + "examples/web": { + "name": "web-examples", + "version": "0.26.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.1", + "@opentelemetry/api-logs": "^0.53.0", + "@opentelemetry/auto-instrumentations-web": "^0.53.0", + "@opentelemetry/context-zone": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.53.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.207.0", + "@opentelemetry/instrumentation": "^0.207.0", + "@opentelemetry/instrumentation-document-load": "^0.53.0", + "@opentelemetry/instrumentation-user-interaction": "^0.52.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.207.0", + "@opentelemetry/propagator-b3": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-logs": "^0.53.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "devDependencies": { + "@babel/core": "^7.21.8", + "babel-loader": "^8.3.0", + "ts-loader": "^6.2.2", + "webpack": "^5.93.0", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.2", + "webpack-merge": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -9746,6 +9783,10 @@ "resolved": "packages/instrumentation-aws-sdk", "link": true }, + "node_modules/@opentelemetry/instrumentation-browser-navigation": { + "resolved": "packages/instrumentation-browser-navigation", + "link": true + }, "node_modules/@opentelemetry/instrumentation-bunyan": { "resolved": "packages/instrumentation-bunyan", "link": true @@ -13884,16 +13925,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/amqplib": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.8.0.tgz", @@ -22981,6 +23012,15 @@ "semver": "bin/semver.js" } }, + "node_modules/karma-jquery": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/karma-jquery/-/karma-jquery-0.2.4.tgz", + "integrity": "sha512-NkEzqc+ulVlOASeQRZh07wB4mv1yUUQPp5natoqcTxl+oXwIB0Hu4/g3uCIJLzvEydAxD7IxWLhZuqIigsdBOQ==", + "dev": true, + "peerDependencies": { + "karma": ">=0.9" + } + }, "node_modules/karma-mocha": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-2.0.1.tgz", @@ -32356,6 +32396,43 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -37839,6 +37916,28 @@ "@opentelemetry/api": "^1.3.0" } }, + "packages/instrumentation-browser-navigation": { + "name": "@opentelemetry/instrumentation-browser-navigation", + "version": "0.54.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "devDependencies": { + "@jsdevtools/coverage-istanbul-loader": "3.0.5", + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "karma-jquery": "0.2.4" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "packages/instrumentation-bunyan": { "name": "@opentelemetry/instrumentation-bunyan", "version": "0.54.0", diff --git a/packages/instrumentation-browser-navigation/LICENSE b/packages/instrumentation-browser-navigation/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/instrumentation-browser-navigation/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/instrumentation-browser-navigation/README.md b/packages/instrumentation-browser-navigation/README.md new file mode 100644 index 0000000000..0c84b0ed93 --- /dev/null +++ b/packages/instrumentation-browser-navigation/README.md @@ -0,0 +1,126 @@ +# OpenTelemetry Instrumentation Browser Navigation + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + +This module provides automatic instrumentation for browser navigation in Web applications. It emits log records via the Logs API to represent: + +- **Page load** (hard navigation) - Initial page loads and full page refreshes +- **Same-document navigations** (soft navigations) - History changes, back/forward navigation, and hash changes + +The instrumentation supports both traditional browser APIs and the modern [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) when available for improved accuracy and reduced duplicate events. + +## Log Record Structure + +Each emitted log record has `eventName = browser.navigation` and includes attributes: + +- `url.full`: Full URL of the current page +- `browser.navigation.same_document`: boolean, true when navigation is within the same document +- `browser.navigation.hash_change`: boolean, true when the navigation involves a hash change +- `browser.navigation.type`: string indicating navigation type: `push` | `replace` | `reload` | `traverse` + +Compatible with OpenTelemetry JS API and SDK `1.0+`. + +## Installation + +```bash +npm install --save @opentelemetry/instrumentation-browser-navigation +``` + +## Usage + +```ts +import { logs } from '@opentelemetry/api-logs'; +import { ConsoleLogRecordExporter, SimpleLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs'; +import { BrowserNavigationInstrumentation } from '@opentelemetry/instrumentation-browser-navigation'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { Resource } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +const loggerProvider = new LoggerProvider({ + resource: new Resource({ [ATTR_SERVICE_NAME]: '' }), +}); +loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(new ConsoleLogRecordExporter())); +logs.setGlobalLoggerProvider(loggerProvider); + +registerInstrumentations({ + instrumentations: [ + new BrowserNavigationInstrumentation({ + // Enable the instrumentation (default: true) + enabled: true, + // Use Navigation API when available for better accuracy (default: true) + useNavigationApiIfAvailable: true, + }), + ], +}); +``` + +## Configuration Options + +The instrumentation accepts the following configuration options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | `boolean` | `true` | Enable/disable the instrumentation | +| `useNavigationApiIfAvailable` | `boolean` | `true` | Use the Navigation API when available for better accuracy | +| `applyCustomLogRecordData` | `function` | `undefined` | Callback to add custom attributes to log records | + +## Navigation API vs Traditional APIs + +When `useNavigationApiIfAvailable` is `true` (default), the instrumentation will: + +- **Use Navigation API** when available (modern browsers) for single, accurate navigation events +- **Fall back to traditional APIs** (history patching, popstate, etc.) in older browsers +- **Prevent duplicate events** by using only one API set at a time + +## Adding Custom Attributes + +If you need to add custom attributes to each navigation event, provide a callback via `applyCustomLogRecordData`: + +```ts +const applyCustom = (logRecord) => { + logRecord.attributes = logRecord.attributes || {}; + logRecord.attributes['example.user.id'] = '123'; +}; + +registerInstrumentations({ + instrumentations: [ + new BrowserNavigationInstrumentation({ applyCustomLogRecordData: applyCustom }), + ], +}); +``` + +## Hash Change Detection + +The instrumentation correctly identifies hash changes based on URL comparison: + +- **Hash change = true**: When URLs are identical except for the hash part + - `/page` → `/page#section` ✅ + - `/page#old` → `/page#new` ✅ +- **Hash change = false**: When the base URL changes or hash is removed + - `/page1` → `/page2` ❌ + - `/page#section` → `/page` ❌ (removing hash is not a hash change) + +## Navigation Types + +- **`push`**: New navigation (link clicks, `history.pushState()`, direct hash changes) +- **`replace`**: Replacing current entry (`history.replaceState()`) +- **`traverse`**: Back/forward navigation (`history.back()`, `history.forward()`) +- **`reload`**: Page refresh + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us in [GitHub Discussions][discussions-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[discussions-url]: https://github.com/open-telemetry/opentelemetry-js/discussions +[license-url]: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[npm-url]: https://www.npmjs.com/package/@opentelemetry/instrumentation-browser-navigation +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-browser-navigation.svg + diff --git a/packages/instrumentation-browser-navigation/karma.conf.js b/packages/instrumentation-browser-navigation/karma.conf.js new file mode 100644 index 0000000000..5f899152bd --- /dev/null +++ b/packages/instrumentation-browser-navigation/karma.conf.js @@ -0,0 +1,27 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const karmaWebpackConfig = require('../../karma.webpack'); +const karmaBaseConfig = require('../../karma.base'); + +module.exports = config => { + config.set( + Object.assign({}, karmaBaseConfig, { + frameworks: karmaBaseConfig.frameworks.concat(['jquery-1.8.3']), + webpack: karmaWebpackConfig, + }) + ); +}; diff --git a/packages/instrumentation-browser-navigation/package.json b/packages/instrumentation-browser-navigation/package.json new file mode 100644 index 0000000000..c7cf810321 --- /dev/null +++ b/packages/instrumentation-browser-navigation/package.json @@ -0,0 +1,62 @@ +{ + "name": "@opentelemetry/instrumentation-browser-navigation", + "version": "0.54.0", + "description": "OpenTelemetry instrumentation for browser navigation events (page load and same-document navigations)", + "main": "build/src/index.js", + "module": "build/esm/index.js", + "esnext": "build/esnext/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js-contrib", + "scripts": { + "clean": "tsc --build --clean tsconfig.json tsconfig.esm.json tsconfig.esnext.json", + "prewatch": "npm run version:update", + "version:update": "node ../../scripts/version-update.js", + "compile": "tsc --build tsconfig.json tsconfig.esm.json tsconfig.esnext.json", + "prepublishOnly": "npm run compile", + "tdd": "wtr --watch", + "test:browser": "karma start --single-run", + "watch": "tsc --build -watch tsconfig.json tsconfig.esm.json tsconfig.esnext.json" + }, + "keywords": [ + "opentelemetry", + "browser", + "navigation", + "web", + "logs", + "plugin" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "files": [ + "build/esm/**/*.js", + "build/esm/**/*.map", + "build/esm/**/*.d.ts", + "build/esnext/**/*.js", + "build/esnext/**/*.map", + "build/esnext/**/*.d.ts", + "build/src/**/*.js", + "build/src/**/*.map", + "build/src/**/*.d.ts" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "devDependencies": { + "@jsdevtools/coverage-istanbul-loader": "3.0.5", + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "karma-jquery": "0.2.4" + }, + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-browser-navigation#readme" +} diff --git a/packages/instrumentation-browser-navigation/src/index.ts b/packages/instrumentation-browser-navigation/src/index.ts new file mode 100644 index 0000000000..27b17d1fa4 --- /dev/null +++ b/packages/instrumentation-browser-navigation/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BrowserNavigationInstrumentation } from './instrumentation'; +export type { BrowserNavigationInstrumentationConfig } from './types'; diff --git a/packages/instrumentation-browser-navigation/src/instrumentation.ts b/packages/instrumentation-browser-navigation/src/instrumentation.ts new file mode 100644 index 0000000000..145ca03037 --- /dev/null +++ b/packages/instrumentation-browser-navigation/src/instrumentation.ts @@ -0,0 +1,383 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InstrumentationBase, isWrapped } from '@opentelemetry/instrumentation'; +import { LogRecord } from '@opentelemetry/api-logs'; +import { ATTR_URL_FULL } from '@opentelemetry/semantic-conventions'; +import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; +import { + BrowserNavigationInstrumentationConfig, + NavigationType, + ApplyCustomLogRecordDataFunction, + SanitizeUrlFunction, +} from './types'; +import { isHashChange, defaultSanitizeUrl } from './utils'; + +/** + * This class represents a browser navigation instrumentation plugin + */ +export const EVENT_NAME = 'browser.navigation'; +export const ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT = + 'browser.navigation.same_document'; +export const ATTR_BROWSER_NAVIGATION_HASH_CHANGE = + 'browser.navigation.hash_change'; +export const ATTR_BROWSER_NAVIGATION_HASH_TYPE = 'browser.navigation.type'; + +export class BrowserNavigationInstrumentation extends InstrumentationBase { + applyCustomLogRecordData: ApplyCustomLogRecordDataFunction | undefined = + undefined; + sanitizeUrl: SanitizeUrlFunction = defaultSanitizeUrl; // Initialize with default + private _onLoadHandler?: () => void; + private _onPopStateHandler?: (ev: PopStateEvent) => void; + private _onNavigateHandler?: (ev: Event) => void; + private _lastUrl: string = location.href; + private _hasProcessedInitialLoad: boolean = false; + + /** + * + * @param config + */ + constructor(config: BrowserNavigationInstrumentationConfig) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this.applyCustomLogRecordData = config?.applyCustomLogRecordData; + // Override default with custom sanitizer if provided + if (config?.sanitizeUrl) { + this.sanitizeUrl = config.sanitizeUrl; + } + } + + init() {} + + /** + * callback to be executed when using hard navigation + */ + private _onHardNavigation() { + const navLogRecord: LogRecord = { + eventName: EVENT_NAME, + attributes: { + [ATTR_URL_FULL]: this.sanitizeUrl(document.documentURI), + [ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT]: false, + [ATTR_BROWSER_NAVIGATION_HASH_CHANGE]: false, + }, + }; + this._applyCustomLogRecordData(navLogRecord, this.applyCustomLogRecordData); + this.logger.emit(navLogRecord); + } + + /** + * callback to be executed when using soft navigation + */ + private _onSoftNavigation( + changeState: string | null | undefined, + navigationEvent?: any + ) { + const referrerUrl = this._lastUrl; + const currentUrl = + changeState === 'currententrychange' && navigationEvent?.target?.currentEntry?.url + ? navigationEvent.target.currentEntry.url + : location.href; + + if (referrerUrl === currentUrl) { + return; + } + + const navType = this._mapChangeStateToType(changeState, navigationEvent); + const sameDocument = this._determineSameDocument( + changeState, + navigationEvent, + referrerUrl, + currentUrl + ); + const hashChange = this._determineHashChange( + changeState, + navigationEvent, + referrerUrl, + currentUrl + ); + const navLogRecord: LogRecord = { + eventName: EVENT_NAME, + attributes: { + [ATTR_URL_FULL]: this.sanitizeUrl(currentUrl), + [ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT]: sameDocument, + [ATTR_BROWSER_NAVIGATION_HASH_CHANGE]: hashChange, + ...(navType ? { [ATTR_BROWSER_NAVIGATION_HASH_TYPE]: navType } : {}), + }, + }; + this._applyCustomLogRecordData(navLogRecord, this.applyCustomLogRecordData); + this.logger.emit(navLogRecord); + + // Update the last known URL after processing + this._lastUrl = currentUrl; + } + + /** + * executes callback {_onDOMContentLoaded } when the page is viewed + */ + private _waitForPageLoad() { + // Check if document has already loaded completely + if (document.readyState === 'complete' && !this._hasProcessedInitialLoad) { + this._hasProcessedInitialLoad = true; + // Use setTimeout to allow tests to reset exporter before this fires + setTimeout(() => this._onHardNavigation(), 0); + return; + } + + // Ensure previous handler is removed before adding a new one + if (this._onLoadHandler) { + document.removeEventListener('DOMContentLoaded', this._onLoadHandler); + } + this._onLoadHandler = () => { + if (!this._hasProcessedInitialLoad) { + this._hasProcessedInitialLoad = true; + this._onHardNavigation(); + } + }; + document.addEventListener('DOMContentLoaded', this._onLoadHandler); + } + + /** + * implements enable function + */ + override enable() { + const cfg = this.getConfig() as BrowserNavigationInstrumentationConfig; + const useNavigationApiIfAvailable = !!cfg.useNavigationApiIfAvailable; + const navigationApi = + useNavigationApiIfAvailable && + ((window as any).navigation as EventTarget); + + // Only patch history API if Navigation API is not available + if (!navigationApi) { + this._patchHistoryApi(); + } + + // Always listen for page load + this._waitForPageLoad(); + + if (navigationApi) { + if (this._onNavigateHandler) { + navigationApi.removeEventListener('currententrychange', this._onNavigateHandler); + this._onNavigateHandler = undefined; + } + this._onNavigateHandler = (event: any) => { + this._onSoftNavigation('currententrychange', event); + }; + navigationApi.addEventListener('currententrychange', this._onNavigateHandler); + } else { + if (this._onPopStateHandler) { + window.removeEventListener('popstate', this._onPopStateHandler); + this._onPopStateHandler = undefined; + } + this._onPopStateHandler = () => { + this._onSoftNavigation('popstate'); + }; + window.addEventListener('popstate', this._onPopStateHandler); + } + } + + /** + * implements disable function + */ + override disable() { + this._unpatchHistoryApi(); + if (this._onLoadHandler) { + document.removeEventListener('DOMContentLoaded', this._onLoadHandler); + this._onLoadHandler = undefined; + } + if (this._onPopStateHandler) { + window.removeEventListener('popstate', this._onPopStateHandler); + this._onPopStateHandler = undefined; + } + if (this._onNavigateHandler) { + try { + const navigationApi = (window as any).navigation as EventTarget; + navigationApi?.removeEventListener?.( + 'currententrychange', + this._onNavigateHandler + ); + } catch { + // Ignore errors when removing Navigation API listeners + } + this._onNavigateHandler = undefined; + } + // Reset the initial load flag so it can be processed again if re-enabled + this._hasProcessedInitialLoad = false; + } + + /** + * Patches the history api method + */ + _patchHistoryMethod(changeState: string) { + const plugin = this; + return (original: any) => { + return function patchHistoryMethod(this: History, ...args: unknown[]) { + const result = original.apply(this, args); + const currentUrl = location.href; + if (currentUrl !== plugin._lastUrl) { + plugin._onSoftNavigation(changeState); + } + return result; + }; + }; + } + + private _patchHistoryApi(): void { + // unpatching here disables other instrumentation that use the same api to wrap history, commenting it out + // this._unpatchHistoryApi(); + this._wrap( + history, + 'replaceState', + this._patchHistoryMethod('replaceState') + ); + this._wrap(history, 'pushState', this._patchHistoryMethod('pushState')); + } + /** + * unpatch the history api methods + */ + _unpatchHistoryApi() { + if (isWrapped(history.replaceState)) this._unwrap(history, 'replaceState'); + if (isWrapped(history.pushState)) this._unwrap(history, 'pushState'); + } + + /** + * + * @param logRecord + * @param applyCustomLogRecordData + * Add custom data to the event + */ + _applyCustomLogRecordData( + logRecord: LogRecord, + applyCustomLogRecordData: ApplyCustomLogRecordDataFunction | undefined + ) { + if (applyCustomLogRecordData) { + applyCustomLogRecordData(logRecord); + } + } + + + private _determineSameDocument( + changeState?: string | null, + navigationEvent?: any, + fromUrl?: string, + toUrl?: string + ): boolean { + // For Navigation API currententrychange events + if (changeState === 'currententrychange') { + // For currententrychange, we can check if the navigation was same-document + // by comparing origins or checking if it's a SPA navigation + if (fromUrl && toUrl) { + try { + const fromURL = new URL(fromUrl); + const toURL = new URL(toUrl); + return fromURL.origin === toURL.origin; + } catch { + return true; // Fallback to same document + } + } + // Default to true for same-document navigations in SPAs + return true; + } + + // For other navigation types, determine based on URL comparison + if (fromUrl && toUrl) { + try { + const fromURL = new URL(fromUrl); + const toURL = new URL(toUrl); + // Same document if origin is the same (cross-origin navigations are always different documents) + // In SPAs, route changes via pushState/replaceState are same-document navigations + return fromURL.origin === toURL.origin; + } catch { + // Fallback: assume same document for relative URLs or parsing errors + return true; + } + } + + // Default: if we can't determine URLs, assume it's a same-document navigation + // This handles cases where URL comparison fails + return true; + } + + /** + * Determines if navigation is a hash change based on URL comparison + * A hash change is true if the URLs are the same except for the hash part + */ + private _determineHashChange( + changeState?: string | null, + navigationEvent?: any, + fromUrl?: string, + toUrl?: string + ): boolean { + // For Navigation API currententrychange events, determine based on URL comparison + if (changeState === 'currententrychange') { + if (fromUrl && toUrl) { + return isHashChange(fromUrl, toUrl); + } + return false; + } + + // For all other cases, determine based on URL comparison + if (fromUrl && toUrl) { + return isHashChange(fromUrl, toUrl); + } + + return false; + } + + + private _mapChangeStateToType( + changeState?: string | null, + navigationEvent?: any + ): NavigationType | undefined { + // For Navigation API currententrychange events + if (changeState === 'currententrychange') { + // First, try to get navigation type from the currententrychange event itself + if (navigationEvent?.navigationType) { + const navType = navigationEvent.navigationType; + switch (navType) { + case 'traverse': + return 'traverse'; + case 'replace': + return 'replace'; + case 'reload': + return 'reload'; + case 'push': + default: + return 'push'; + } + } + + // Fallback: For currententrychange events without type info, default to 'push' + // Most programmatic navigations (history.pushState, link clicks) are 'push' operations + return 'push'; + } + + switch (changeState) { + case 'pushState': + return 'push'; + case 'replaceState': + return 'replace'; + case 'popstate': + // For popstate, we need to check if it's a hash change to determine type + // This is called after _determineHashChange, so we need to check URLs here too + return 'traverse'; // Default to traverse, but hash changes will be handled specially + case 'hashchange': + return 'push'; + case 'navigate': + return 'push'; + default: + return undefined; + } + } +} diff --git a/packages/instrumentation-browser-navigation/src/types.ts b/packages/instrumentation-browser-navigation/src/types.ts new file mode 100644 index 0000000000..4608aa2f25 --- /dev/null +++ b/packages/instrumentation-browser-navigation/src/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { LogRecord } from '@opentelemetry/api-logs'; + +/** + * BrowserNavigationInstrumentationConfig + */ +export interface BrowserNavigationInstrumentationConfig + extends InstrumentationConfig { + applyCustomLogRecordData?: ApplyCustomLogRecordDataFunction; + /** Use the Navigation API navigate event if available (experimental) */ + useNavigationApiIfAvailable?: boolean; + /** Custom function to sanitize URLs before adding to log records */ + sanitizeUrl?: SanitizeUrlFunction; +} + +export interface ApplyCustomLogRecordDataFunction { + (logRecord: LogRecord): void; +} + +export interface SanitizeUrlFunction { + (url: string): string; +} + +export type NavigationType = 'push' | 'replace' | 'reload' | 'traverse'; diff --git a/packages/instrumentation-browser-navigation/src/utils.ts b/packages/instrumentation-browser-navigation/src/utils.ts new file mode 100644 index 0000000000..daada22998 --- /dev/null +++ b/packages/instrumentation-browser-navigation/src/utils.ts @@ -0,0 +1,118 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Default URL sanitization function that redacts credentials and sensitive query parameters. + * This is the default implementation used when no custom sanitizeUrl callback is provided. + * + * @param url - The URL to sanitize + * @returns The sanitized URL with credentials and sensitive parameters redacted + */ +export function defaultSanitizeUrl(url: string): string { + const sensitiveParams = [ + 'password', + 'passwd', + 'secret', + 'api_key', + 'apikey', + 'auth', + 'authorization', + 'token', + 'access_token', + 'refresh_token', + 'jwt', + 'session', + 'sessionid', + 'key', + 'private_key', + 'client_secret', + 'client_id', + 'signature', + 'hash', + ]; + try { + const urlObj = new URL(url); + + // Redact credentials if present + if (urlObj.username || urlObj.password) { + urlObj.username = 'REDACTED'; + urlObj.password = 'REDACTED'; + } + + // Redact sensitive query parameters + for (const param of sensitiveParams) { + if (urlObj.searchParams.has(param)) { + urlObj.searchParams.set(param, 'REDACTED'); + } + } + + return urlObj.toString(); + } catch { + // If URL parsing fails, redact credentials and sensitive query parameters + let sanitized = url.replace(/\/\/[^:]+:[^@]+@/, '//REDACTED:REDACTED@'); + + for (const param of sensitiveParams) { + // Match param=value or param%3Dvalue (URL encoded) + const regex = new RegExp(`([?&]${param}(?:%3D|=))[^&]*`, 'gi'); + sanitized = sanitized.replace(regex, '$1REDACTED'); + } + + return sanitized; + } +} + +/** + * Determines if navigation between two URLs represents a hash change. + * A hash change is true if the URLs are the same except for the hash part. + * + * @param fromUrl - The source URL + * @param toUrl - The destination URL + * @returns true if this represents a hash change navigation + */ +export function isHashChange(fromUrl: string, toUrl: string): boolean { + try { + const a = new URL(fromUrl, window.location.origin); + const b = new URL(toUrl, window.location.origin); + // Only consider it a hash change if: + // 1. Base URL (origin + pathname + search) is identical + // 2. Both URLs have hashes and they're different, OR we're adding a hash + const sameBase = + a.origin === b.origin && + a.pathname === b.pathname && + a.search === b.search; + const fromHasHash = a.hash !== ''; + const toHasHash = b.hash !== ''; + const hashesAreDifferent = a.hash !== b.hash; + + return ( + sameBase && + hashesAreDifferent && + ((fromHasHash && toHasHash) || (!fromHasHash && toHasHash)) + ); + } catch { + // Fallback: check if base URLs are identical and we're changing/adding hash (not removing) + const fromBase = fromUrl.split('#')[0]; + const toBase = toUrl.split('#')[0]; + const fromHash = fromUrl.split('#')[1] || ''; + const toHash = toUrl.split('#')[1] || ''; + + const sameBase = fromBase === toBase; + const hashesAreDifferent = fromHash !== toHash; + const notRemovingHash = toHash !== ''; // Only true if we're not removing the hash + + return sameBase && hashesAreDifferent && notRemovingHash; + } +} diff --git a/packages/instrumentation-browser-navigation/test/index-webpack.ts b/packages/instrumentation-browser-navigation/test/index-webpack.ts new file mode 100644 index 0000000000..061a48ccfa --- /dev/null +++ b/packages/instrumentation-browser-navigation/test/index-webpack.ts @@ -0,0 +1,20 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const testsContext = require.context('.', true, /test$/); +testsContext.keys().forEach(testsContext); + +const srcContext = require.context('.', true, /src$/); +srcContext.keys().forEach(srcContext); diff --git a/packages/instrumentation-browser-navigation/test/navigation.test.ts b/packages/instrumentation-browser-navigation/test/navigation.test.ts new file mode 100644 index 0000000000..643c5e96ef --- /dev/null +++ b/packages/instrumentation-browser-navigation/test/navigation.test.ts @@ -0,0 +1,734 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + LoggerProvider, + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ReadableLogRecord, +} from '@opentelemetry/sdk-logs'; + +import * as sinon from 'sinon'; +import { BrowserNavigationInstrumentation } from '../src'; +import { + EVENT_NAME, + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT, + ATTR_BROWSER_NAVIGATION_HASH_CHANGE, + ATTR_BROWSER_NAVIGATION_HASH_TYPE, +} from '../src/instrumentation'; +import { ATTR_URL_FULL } from '@opentelemetry/semantic-conventions'; +import { logs } from '@opentelemetry/api-logs'; +import * as assert from 'assert'; +// registerInstrumentations removed - using plugin.enable() directly + +describe('Browser Navigation Instrumentation', () => { + let instrumentation: BrowserNavigationInstrumentation; + const sandbox = sinon.createSandbox(); + + const exporter = new InMemoryLogRecordExporter(); + const logRecordProcessor = new SimpleLogRecordProcessor(exporter); + const provider = new LoggerProvider({ + processors: [logRecordProcessor], + }); + logs.setGlobalLoggerProvider(provider); + + afterEach(() => { + if (instrumentation) { + instrumentation.disable(); + } + exporter.reset(); + sandbox.restore(); + }); + + describe('constructor', () => { + it('should construct an instance', () => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: false, + }); + + assert.strictEqual(exporter.getFinishedLogRecords().length, 0); + assert.ok(instrumentation instanceof BrowserNavigationInstrumentation); + }); + }); + + describe('export navigation LogRecord', () => { + it("should export LogRecord for browser.navigation when 'DOMContentLoaded' event is fired", done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: false, + useNavigationApiIfAvailable: false, // Disable Navigation API to test DOMContentLoaded path + }); + + const spy = sandbox.spy(document, 'addEventListener'); + // instrumentation.enable(); + instrumentation.enable(); + + setTimeout(() => { + // Check if readyState was 'complete' when instrumentation enabled + const wasDocumentReady = document.readyState === 'complete'; + + if (wasDocumentReady) { + // If document was ready, no listener should be registered (readyState check fired) + assert.ok(!spy.called, 'No DOMContentLoaded listener should be registered when document is already ready'); + } else { + // If document wasn't ready, listener should be registered + assert.ok(spy.calledOnce, 'DOMContentLoaded listener should be registered when document is not ready'); + } + + // Dispatch fake DOMContentLoaded event + document.dispatchEvent(new Event('DOMContentLoaded')); + + // Wait a bit for any events to process + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + assert.ok(records.length >= 1, `Expected at least 1 record, got ${records.length}`); + + // Verify the navigation record has correct properties + const navLogRecord = records[records.length - 1] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + // URL should be sanitized but documentURI typically doesn't have credentials + const expectedUrl = document.documentURI as string; + assert.deepEqual(navLogRecord.attributes, { + [ATTR_URL_FULL]: expectedUrl, + [ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT]: false, + [ATTR_BROWSER_NAVIGATION_HASH_CHANGE]: false, + }); + done(); + }, 10); + }, 10); + }); + + it('should export LogRecord for browser.navigation with type push when history.pushState() is called', done => { + const vpStartTime = 16842729000 * 1000000; + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + // Test should work with either Navigation API setting + applyCustomLogRecordData: logRecord => { + if (!logRecord.attributes) { + (logRecord as any).attributes = {}; + } + (logRecord.attributes as any)['vp.startTime'] = vpStartTime; + }, + }); + + // Clear existing records and get baseline + exporter.reset(); + const initialCount = exporter.getFinishedLogRecords().length; + + history.pushState({}, '', '/dummy1.html'); + + // Allow time for both potential events (History API + Navigation API) + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + const newRecords = records.slice(initialCount); + + // Should have at least one record for our navigation + assert.ok(newRecords.length >= 1, 'Should record the navigation'); + + // Find the record for our test navigation + const testRecord = newRecords.find(r => + (r.attributes as any)['url.full']?.includes('/dummy1.html') + ); + + assert.ok(testRecord, 'Should find record for /dummy1.html navigation'); + + // Verify the record has correct properties + assert.strictEqual(testRecord.eventName, EVENT_NAME); + assert.strictEqual( + (testRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT], + true + ); + assert.strictEqual( + (testRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_CHANGE], + false + ); + assert.strictEqual( + (testRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_TYPE], + 'push' + ); + assert.strictEqual( + (testRecord.attributes as any)['vp.startTime'], + vpStartTime + ); + done(); + }, 10); + }); + + it('should export LogRecord for browser.navigation with type replace when history.replaceState() is called', done => { + const vpStartTime = 16842729000 * 1000000; + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: false, // Disable Navigation API to test history API path + applyCustomLogRecordData: logRecord => { + if (!logRecord.attributes) { + (logRecord as any).attributes = {}; + } + (logRecord.attributes as any)['vp.startTime'] = vpStartTime; + }, + }); + + history.replaceState({}, '', '/dummy2.html'); + + assert.strictEqual(exporter.getFinishedLogRecords().length, 1); + + const navLogRecord = + exporter.getFinishedLogRecords()[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + // URL should be sanitized - check it matches current location + const actualUrl = (navLogRecord.attributes as any)[ATTR_URL_FULL]; + assert.ok( + actualUrl.includes(window.location.pathname), + `Expected URL to contain pathname ${window.location.pathname}, got ${actualUrl}` + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT], + true + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_CHANGE], + false + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_TYPE], + 'replace' + ); + assert.strictEqual( + (navLogRecord.attributes as any)['vp.startTime'], + vpStartTime + ); + done(); + }); + + it('should not export LogRecord for browser.navigation if the URL is not changed.', done => { + const vpStartTime = 16842729000 * 1000000; + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: false, // Disable Navigation API to test history API path + applyCustomLogRecordData: logRecord => { + if (!logRecord.attributes) { + (logRecord as any).attributes = {}; + } + (logRecord.attributes as any)['vp.startTime'] = vpStartTime; + }, + }); + + // previously captured referrer is no longer asserted + history.pushState({}, '', '/dummy3.html'); + assert.strictEqual(exporter.getFinishedLogRecords().length, 1); + + const navLogRecord = + exporter.getFinishedLogRecords()[0] as any as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + // URL should be sanitized - check it matches current location + const actualUrl = (navLogRecord.attributes as any)[ATTR_URL_FULL]; + assert.ok( + actualUrl.includes(window.location.pathname), + `Expected URL to contain pathname ${window.location.pathname}, got ${actualUrl}` + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT], + true + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_CHANGE], + false + ); + + // previously captured second referrer is no longer asserted + history.pushState({}, '', '/dummy3.html'); + assert.strictEqual(exporter.getFinishedLogRecords().length, 1); + + const navLogRecord2 = + exporter.getFinishedLogRecords()[0] as any as ReadableLogRecord; + assert.strictEqual(navLogRecord2.eventName, EVENT_NAME); + // URL should be sanitized - check it matches current location + const actualUrl2 = (navLogRecord2.attributes as any)[ATTR_URL_FULL]; + assert.ok( + actualUrl2.includes(window.location.pathname), + `Expected URL to contain pathname ${window.location.pathname}, got ${actualUrl2}` + ); + assert.strictEqual( + (navLogRecord2.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true + ); + assert.strictEqual( + (navLogRecord2.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_CHANGE], + false + ); + + done(); + }); + + it('should export LogRecord with hash_change=true when location.hash changes', done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + }); + instrumentation.enable(); + + // Clear any existing records and set up initial state + exporter.reset(); + + const newHash = `#hash-${Date.now()}`; + + // Wait for hashchange event and check for records with hash_change=true + const checkForHashChangeRecord = () => { + const records = exporter.getFinishedLogRecords(); + // Look for a record with hash_change=true (regardless of type) + const hashChangeRecord = records.find( + record => + (record.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_CHANGE] === + true + ); + + if (hashChangeRecord) { + assert.strictEqual(hashChangeRecord.eventName, EVENT_NAME); + assert.strictEqual( + (hashChangeRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true + ); + assert.strictEqual( + (hashChangeRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_CHANGE + ], + true + ); + // Accept either 'push' or 'traverse' as browsers may vary + const navType = (hashChangeRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_TYPE + ]; + assert.ok( + navType === 'push' || navType === 'traverse', + `Expected navigation type to be 'push' or 'traverse', got '${navType}'` + ); + done(); + } else { + // Keep checking for up to 100ms + setTimeout(checkForHashChangeRecord, 10); + } + }; + + // Trigger hash change and start checking + location.hash = newHash; + setTimeout(checkForHashChangeRecord, 10); + }); + + it('should export LogRecord with type traverse when history.back() triggers a popstate', done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + }); + instrumentation.enable(); + + // Setup history stack + history.pushState({}, '', '/nav-traverse-1'); + history.pushState({}, '', '/nav-traverse-2'); + + // Clear records and set up state + exporter.reset(); + + // Listen for popstate event directly + const popstateHandler = () => { + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + if (records.length === 0) { + done(new Error('No records found after popstate')); + return; + } + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_CHANGE + ], + false + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_TYPE], + 'traverse' + ); + window.removeEventListener('popstate', popstateHandler); + done(); + }, 10); + }; + + window.addEventListener('popstate', popstateHandler); + history.back(); + }); + + it('should export LogRecord when Navigation API currententrychange event is fired (if available)', done => { + // Check if Navigation API is actually available in the test environment + if (!(window as any).navigation) { + console.log( + 'Navigation API not available in test environment, skipping test' + ); + done(); + return; + } + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: true, + }); + + instrumentation.enable(); + + // Wait for any readyState-triggered events, then clear records + setTimeout(() => { + exporter.reset(); + + // Use actual Navigation API if available + const navigation = (window as any).navigation; + let entryChangeHandler: ((event: any) => void) | null = null; + + const cleanup = () => { + if (entryChangeHandler && navigation) { + navigation.removeEventListener('currententrychange', entryChangeHandler); + } + }; + + entryChangeHandler = (event: any) => { + // Let the navigation complete, then check records + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + if (records.length >= 1) { + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true // currententrychange events are typically same-document + ); + // Hash change detection is based on URL comparison for currententrychange + assert.ok( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_CHANGE] !== undefined + ); + cleanup(); + done(); + } + }, 10); + }; + + navigation.addEventListener('currententrychange', entryChangeHandler); + + // Trigger a navigation that should fire the currententrychange event + try { + // Use history.pushState to trigger currententrychange (safer than navigation.navigate) + history.pushState({}, '', '?test=nav-api'); + + // Set timeout to check if currententrychange fired + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + if (records.length >= 1) { + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true + ); + cleanup(); + done(); + } else { + // If no records, the test environment might not support currententrychange + console.log('No navigation records found, currententrychange might not be supported'); + cleanup(); + done(); + } + }, 50); + } catch (_error) { + // Fallback if Navigation API methods fail + console.log( + 'Navigation API methods not fully supported, using fallback' + ); + history.pushState({}, '', '?test=fallback'); + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + if (records.length >= 1) { + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true + ); + cleanup(); + done(); + } + }, 50); + } + + // Cleanup timeout in case test hangs + setTimeout(() => { + cleanup(); + done(new Error('Test timeout - Navigation API event not fired')); + }, 1000); + }, 10); // Close the setTimeout for readyState wait + }); + + it('should export LogRecord with Navigation API hash change detection', done => { + // Check if Navigation API is actually available in the test environment + if (!(window as any).navigation) { + console.log( + 'Navigation API not available in test environment, skipping test' + ); + done(); + return; + } + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: true, + }); + instrumentation.enable(); + + // Wait for any readyState-triggered events, then clear records + setTimeout(() => { + exporter.reset(); + + const navigation = (window as any).navigation; + let entryChangeHandler: ((event: any) => void) | null = null; + + const cleanup = () => { + if (entryChangeHandler && navigation) { + navigation.removeEventListener('currententrychange', entryChangeHandler); + } + }; + + entryChangeHandler = (event: any) => { + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + if (records.length >= 1) { + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT + ], + true + ); + // For currententrychange, hash change detection is based on URL comparison + const hashChange = (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_CHANGE + ]; + assert.ok(typeof hashChange === 'boolean'); + + const navType = (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_TYPE + ]; + assert.ok(navType === 'push' || navType === 'traverse'); + cleanup(); + done(); + } + }, 10); + }; + + navigation.addEventListener('currententrychange', entryChangeHandler); + + // Trigger a hash navigation using traditional method (more reliable) + location.hash = '#section1'; + + // Set timeout to check if currententrychange fired + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + if (records.length >= 1) { + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + assert.strictEqual(navLogRecord.eventName, EVENT_NAME); + cleanup(); + done(); + } else { + console.log('No hash navigation records found'); + cleanup(); + done(); + } + }, 100); + + // Cleanup timeout + setTimeout(() => { + cleanup(); + done(new Error('Test timeout - Hash change navigation not detected')); + }, 1000); + }, 10); // Close the setTimeout for readyState wait + }); + + it('should sanitize URLs with credentials using default sanitizer', done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: false, // Test history API path + }); + instrumentation.enable(); + + // Wait for any readyState-triggered events, then clear records + setTimeout(() => { + exporter.reset(); + + // Use relative URL to avoid cross-origin issues in tests + const testUrl = '/path?api_key=secret123&normal=value'; + + // Simulate navigation to URL with credentials + history.pushState({}, '', testUrl); + + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + assert.ok(records.length >= 1, 'Should have at least one record'); + + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + const sanitized = (navLogRecord.attributes as any)['url.full'] as string; + + assert.ok( + sanitized.includes('api_key=REDACTED'), + 'Should redact sensitive query params' + ); + assert.ok( + sanitized.includes('normal=value'), + 'Should preserve normal query params' + ); + done(); + }, 10); + }, 10); // Close the setTimeout for readyState wait + }); + + it('should use custom sanitizeUrl callback when provided', done => { + const customSanitizer = (url: string) => { + // Custom sanitizer that only removes passwords, keeps everything else + return url.replace(/password=[^&]*/gi, 'password=CUSTOM_REDACTED'); + }; + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: false, // Test history API path + sanitizeUrl: customSanitizer, + }); + instrumentation.enable(); + + // Clear any existing records + exporter.reset(); + + // Test URL with password parameter (relative to avoid cross-origin issues) + const testUrl = '/path?password=secret123&api_key=keepthis&normal=value'; + + history.pushState({}, '', testUrl); + + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + assert.ok(records.length >= 1, 'Should have at least one record'); + + const navLogRecord = records.slice(-1)[0] as ReadableLogRecord; + const sanitized = (navLogRecord.attributes as any)['url.full'] as string; + + assert.ok( + sanitized.includes('password=CUSTOM_REDACTED'), + 'Should use custom sanitization for password' + ); + assert.ok( + sanitized.includes('api_key=keepthis'), + 'Should preserve api_key (not redacted by custom sanitizer)' + ); + assert.ok( + sanitized.includes('normal=value'), + 'Should preserve normal query params' + ); + done(); + }, 10); + }); + + it('should work with Navigation API enabled', done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: true, + }); + instrumentation.enable(); + + // Clear any existing records and set baseline + exporter.reset(); + + // Trigger a navigation using history API + history.pushState({}, '', '/fallback-test'); + + setTimeout(() => { + const records = exporter.getFinishedLogRecords(); + // Should have at least one record (may have more due to test environment) + assert.ok( + records.length >= 1, + 'Should have at least one navigation record' + ); + + // Find our test record + const testRecord = records.find(r => + (r.attributes as any)['url.full']?.includes('/fallback-test') + ); + + assert.ok(testRecord, 'Should find navigation record for our test URL'); + assert.strictEqual(testRecord.eventName, EVENT_NAME); + assert.strictEqual( + (testRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_TYPE], + 'push' + ); + assert.strictEqual( + (testRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT], + true + ); + + done(); + }, 10); + }); + + it('should not attach Navigation API listeners when disabled', done => { + // Skip if Navigation API is not available + if (!(window as any).navigation) { + done(); + return; + } + + // Spy on Navigation API addEventListener + const navigationSpy = sandbox.spy( + (window as any).navigation, + 'addEventListener' + ); + + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: false, + }); + instrumentation.enable(); + + // Verify Navigation API addEventListener was not called for 'navigate' events + const navigateListenerCalls = navigationSpy + .getCalls() + .filter(call => call.args[0] === 'navigate'); + assert.strictEqual( + navigateListenerCalls.length, + 0, + 'Navigation API should not be used when disabled' + ); + + done(); + }); + }); +}); diff --git a/packages/instrumentation-browser-navigation/test/utils.test.ts b/packages/instrumentation-browser-navigation/test/utils.test.ts new file mode 100644 index 0000000000..6b23a69cda --- /dev/null +++ b/packages/instrumentation-browser-navigation/test/utils.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import { isHashChange, defaultSanitizeUrl } from '../src/utils'; + +describe('utils', () => { + describe('isHashChange', () => { + it('should return true when adding a hash to the same URL', () => { + const fromUrl = 'https://example.com/page'; + const toUrl = 'https://example.com/page#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should return true when changing hash on the same URL', () => { + const fromUrl = 'https://example.com/page#section1'; + const toUrl = 'https://example.com/page#section2'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should return false when removing a hash', () => { + const fromUrl = 'https://example.com/page#section1'; + const toUrl = 'https://example.com/page'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should return false when URLs have different paths', () => { + const fromUrl = 'https://example.com/page1#section1'; + const toUrl = 'https://example.com/page2#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should return false when URLs have different origins', () => { + const fromUrl = 'https://example.com/page#section1'; + const toUrl = 'https://other.com/page#section2'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should return false when URLs have different query parameters', () => { + const fromUrl = 'https://example.com/page?param=1#section1'; + const toUrl = 'https://example.com/page?param=2#section2'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should return true when only hash differs with same query parameters', () => { + const fromUrl = 'https://example.com/page?param=1#section1'; + const toUrl = 'https://example.com/page?param=1#section2'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should return true when adding hash with query parameters', () => { + const fromUrl = 'https://example.com/page?param=1'; + const toUrl = 'https://example.com/page?param=1#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should return false when URLs are identical', () => { + const fromUrl = 'https://example.com/page#section1'; + const toUrl = 'https://example.com/page#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should return false when both URLs have no hash', () => { + const fromUrl = 'https://example.com/page'; + const toUrl = 'https://example.com/page'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + describe('fallback behavior with invalid URLs', () => { + it('should handle malformed URLs in fallback mode', () => { + const fromUrl = 'not-a-valid-url'; + const toUrl = 'not-a-valid-url#section1'; + // This should trigger the fallback logic + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should return false in fallback mode when removing hash', () => { + const fromUrl = 'invalid-url#section1'; + const toUrl = 'invalid-url'; + // Fallback mode should return false when removing hash + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should handle URLs with invalid protocols in fallback mode', () => { + const fromUrl = 'invalid://example.com/page'; + const toUrl = 'invalid://example.com/page#section1'; + // This should trigger the fallback logic and return true for adding hash + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + }); + + describe('edge cases', () => { + it('should handle empty hash correctly', () => { + const fromUrl = 'https://example.com/page#'; + const toUrl = 'https://example.com/page#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should handle URLs with ports', () => { + const fromUrl = 'https://example.com:8080/page'; + const toUrl = 'https://example.com:8080/page#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + + it('should handle URLs with different ports as different origins', () => { + const fromUrl = 'https://example.com:8080/page#section1'; + const toUrl = 'https://example.com:9090/page#section2'; + assert.strictEqual(isHashChange(fromUrl, toUrl), false); + }); + + it('should handle complex query parameters', () => { + const fromUrl = 'https://example.com/page?a=1&b=2&c=3'; + const toUrl = 'https://example.com/page?a=1&b=2&c=3#section1'; + assert.strictEqual(isHashChange(fromUrl, toUrl), true); + }); + }); + }); + + describe('defaultSanitizeUrl', () => { + it('should redact username and password from URL', () => { + const url = 'https://user:pass@example.com/path'; + const sanitized = defaultSanitizeUrl(url); + assert.strictEqual(sanitized, 'https://REDACTED:REDACTED@example.com/path'); + }); + + it('should redact sensitive query parameters', () => { + const url = 'https://example.com/path?api_key=secret123&normal=value'; + const sanitized = defaultSanitizeUrl(url); + assert.ok(sanitized.includes('api_key=REDACTED')); + assert.ok(sanitized.includes('normal=value')); + }); + + it('should handle multiple sensitive parameters', () => { + const url = 'https://example.com/path?token=abc123&password=secret&normal=value'; + const sanitized = defaultSanitizeUrl(url); + assert.ok(sanitized.includes('token=REDACTED')); + assert.ok(sanitized.includes('password=REDACTED')); + assert.ok(sanitized.includes('normal=value')); + }); + + it('should preserve fragment/hash in URL', () => { + const url = 'https://example.com/path?api_key=secret#section1'; + const sanitized = defaultSanitizeUrl(url); + assert.ok(sanitized.includes('#section1')); + assert.ok(sanitized.includes('api_key=REDACTED')); + }); + + it('should handle invalid URLs with fallback logic', () => { + const url = 'invalid://user:pass@example.com/path?api_key=secret123'; + const sanitized = defaultSanitizeUrl(url); + assert.ok(sanitized.includes('REDACTED:REDACTED')); + assert.ok(sanitized.includes('api_key=REDACTED')); + }); + + it('should return URL unchanged if no sensitive data', () => { + const url = 'https://example.com/path?normal=value&other=data'; + const sanitized = defaultSanitizeUrl(url); + assert.strictEqual(sanitized, url); + }); + + it('should handle URL encoded sensitive parameters', () => { + const url = 'https://example.com/path?api%5Fkey=secret123'; + const sanitized = defaultSanitizeUrl(url); + // This tests the fallback regex logic for URL-encoded parameters + assert.ok(sanitized.includes('REDACTED')); + }); + }); +}); diff --git a/packages/instrumentation-browser-navigation/tsconfig.esm.json b/packages/instrumentation-browser-navigation/tsconfig.esm.json new file mode 100644 index 0000000000..a94adff6aa --- /dev/null +++ b/packages/instrumentation-browser-navigation/tsconfig.esm.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.esm.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build/esm", + "tsBuildInfoFile": "build/esm/tsconfig.esm.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/instrumentation-browser-navigation/tsconfig.esnext.json b/packages/instrumentation-browser-navigation/tsconfig.esnext.json new file mode 100644 index 0000000000..65a918cf6b --- /dev/null +++ b/packages/instrumentation-browser-navigation/tsconfig.esnext.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.esnext.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build/esnext", + "tsBuildInfoFile": "build/esnext/tsconfig.esnext.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/instrumentation-browser-navigation/tsconfig.json b/packages/instrumentation-browser-navigation/tsconfig.json new file mode 100644 index 0000000000..bdc94d2213 --- /dev/null +++ b/packages/instrumentation-browser-navigation/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/packages/instrumentation-browser-navigation/web-test-runner.config.mjs b/packages/instrumentation-browser-navigation/web-test-runner.config.mjs new file mode 100644 index 0000000000..d8eaf1f33d --- /dev/null +++ b/packages/instrumentation-browser-navigation/web-test-runner.config.mjs @@ -0,0 +1,43 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { nodeResolve as nodeResolveRollup } from '@rollup/plugin-node-resolve'; +import commonjsRollup from '@rollup/plugin-commonjs'; +import { esbuildPlugin } from '@web/dev-server-esbuild'; +import { fromRollup } from '@web/dev-server-rollup'; +import { chromeLauncher } from '@web/test-runner'; + +const nodeResolve = fromRollup(nodeResolveRollup); +const commonjs = fromRollup(commonjsRollup); + +export default { + files: ['test/**/*.test.ts'], + browsers: [chromeLauncher({ launchOptions: { args: ['--no-sandbox'] } })], + plugins: [ + esbuildPlugin({ ts: true }), + nodeResolve({ + browser: true, + preferBuiltins: false, + modulePaths: ['node_modules', '../../../node_modules'], + }), + commonjs({ + extensions: ['.js', '.ts'], + include: [/node_modules/], + }), + ], + preserveSymlinks: true, + logLevel: 'debug', +}; diff --git a/release-please-config.json b/release-please-config.json index 14cbc77446..18bd96821c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -75,6 +75,7 @@ "packages/propagator-instana": {}, "packages/propagator-ot-trace": {}, "packages/propagator-aws-xray": {}, - "packages/propagator-aws-xray-lambda": {} + "packages/propagator-aws-xray-lambda": {}, + "packages/instrumentation-browser-navigation": {} } } From a90c32c13e9c312eb5bf24e2ebc5924fab047536 Mon Sep 17 00:00:00 2001 From: Abinet Debele Date: Tue, 25 Nov 2025 12:36:32 -0800 Subject: [PATCH 2/3] lint fix --- packages/instrumentation-browser-navigation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/instrumentation-browser-navigation/README.md b/packages/instrumentation-browser-navigation/README.md index 0c84b0ed93..f8e57455ac 100644 --- a/packages/instrumentation-browser-navigation/README.md +++ b/packages/instrumentation-browser-navigation/README.md @@ -60,7 +60,7 @@ registerInstrumentations({ The instrumentation accepts the following configuration options: | Option | Type | Default | Description | -|--------|------|---------|-------------| +| ------ | ---- | ------- | ----------- | | `enabled` | `boolean` | `true` | Enable/disable the instrumentation | | `useNavigationApiIfAvailable` | `boolean` | `true` | Use the Navigation API when available for better accuracy | | `applyCustomLogRecordData` | `function` | `undefined` | Callback to add custom attributes to log records | From 7bc1c495417ec564fb9850eff29e2b278f7389e9 Mon Sep 17 00:00:00 2001 From: Abinet Debele Date: Tue, 25 Nov 2025 13:22:25 -0800 Subject: [PATCH 3/3] fix tests --- .../test/navigation.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/instrumentation-browser-navigation/test/navigation.test.ts b/packages/instrumentation-browser-navigation/test/navigation.test.ts index 643c5e96ef..5bf4505d4c 100644 --- a/packages/instrumentation-browser-navigation/test/navigation.test.ts +++ b/packages/instrumentation-browser-navigation/test/navigation.test.ts @@ -322,13 +322,13 @@ describe('Browser Navigation Instrumentation', () => { done(); } else { // Keep checking for up to 100ms - setTimeout(checkForHashChangeRecord, 10); + setTimeout(checkForHashChangeRecord, 25); } }; // Trigger hash change and start checking location.hash = newHash; - setTimeout(checkForHashChangeRecord, 10); + setTimeout(checkForHashChangeRecord, 25); }); it('should export LogRecord with type traverse when history.back() triggers a popstate', done => { @@ -372,7 +372,7 @@ describe('Browser Navigation Instrumentation', () => { ); window.removeEventListener('popstate', popstateHandler); done(); - }, 10); + }, 50); }; window.addEventListener('popstate', popstateHandler);