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 0a78865051..217ded9f39 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 46f7e5d58f..8f11d39af4 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.3", - "packages/propagator-aws-xray-lambda": "0.55.3" + "packages/propagator-aws-xray-lambda": "0.55.3", + "packages/instrumentation-browser-navigation": "0.54.0" } 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..039d7c2f8b --- /dev/null +++ b/examples/web/examples/index.html @@ -0,0 +1,44 @@ + + + + + + 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/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..a75e0ccc1f 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", @@ -30,25 +30,29 @@ "@babel/core": "^7.21.8", "babel-loader": "^8.3.0", "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/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" }, "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..59b6522fa8 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,13 @@ 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'), }, output: { path: path.resolve(__dirname, 'dist'), @@ -38,15 +42,15 @@ 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', }, @@ -59,14 +63,31 @@ 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' }], + }, }, 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 4066336e89..da48ab9024 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", @@ -2290,7 +2327,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -4018,7 +4054,6 @@ "integrity": "sha512-b7W4snvXYi1T2puUjxamASCCNhNzVSzb/fQUuGSkdjm/AFfJ24jo8kOHQyOcaoArCG71sVQci4vkZaITzl/V1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cucumber/ci-environment": "10.0.1", "@cucumber/cucumber-expressions": "18.0.1", @@ -4181,7 +4216,6 @@ "integrity": "sha512-659CCFsrsyvuBi/Eix1fnhSheMnojSfnBcqJ3IMPNawx7JlrNJDcXYSSdxcUw3n/nG05P+ptCjmiZY3i14p+tA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cucumber/messages": ">=19.1.4 <29" } @@ -4360,7 +4394,6 @@ "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -4371,7 +4404,6 @@ "integrity": "sha512-2LzZtOwYKNlCuNf31ajkrekoy2M4z0Z1QGiPH40n4gf5t8VOUFb7m1ojtR4LmGvZxBGvJZP8voOmRqDWzBzYKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", @@ -7265,7 +7297,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -7601,7 +7632,6 @@ "integrity": "sha512-pzGXp14KF2Q4CDZGQgPK4l8zEg7i6cNkb+10yc8ZA5K41cLe3ZbWW1YxtY2e/glHauOJwTLSVjH4tiRVtOTizg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.8.1", @@ -7684,7 +7714,6 @@ "integrity": "sha512-UVSf0yaWFBC2Zn2FOWABXxCnyG8XNIXrNnvTFpbUFqaJu1YDdwJ7wQBBqxq9CtJT7ILqSmfhOU7HS0d/0EAxpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.0.1", @@ -9222,7 +9251,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -9411,7 +9439,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -9763,6 +9790,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 @@ -10498,7 +10529,6 @@ "integrity": "sha512-EI0Ti5pojD2p7TmcS7RRa+AJVahdQvP/urpcSbK/K9Rlk6+dwMJTQ354pCNGCwfke8x4yKr5+iH85wcERSkwLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -12502,7 +12532,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -12584,7 +12613,6 @@ "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -12860,7 +12888,6 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -14069,7 +14096,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -14199,7 +14225,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14938,7 +14963,6 @@ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -15445,7 +15469,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -17551,8 +17574,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/di": { "version": "0.0.1", @@ -18318,7 +18340,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -23153,6 +23174,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", @@ -24209,7 +24239,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -28147,7 +28176,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -29674,7 +29702,6 @@ "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -30571,7 +30598,6 @@ "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -31070,8 +31096,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -31706,7 +31731,6 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -31826,7 +31850,6 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -31984,7 +32007,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -34988,7 +35010,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -35565,7 +35586,6 @@ "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -36134,7 +36154,6 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -36184,7 +36203,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -36808,7 +36826,6 @@ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -37116,7 +37133,6 @@ "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -37125,8 +37141,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "packages/auto-configuration-propagators": { "name": "@opentelemetry/auto-configuration-propagators", @@ -37413,6 +37428,126 @@ "@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" + }, + "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-browser-navigation/node_modules/@opentelemetry/api-logs": { + "version": "0.203.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.203.0.tgz", + "integrity": "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "packages/instrumentation-browser-navigation/node_modules/@opentelemetry/core": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", + "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", + "dev": true, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "packages/instrumentation-browser-navigation/node_modules/@opentelemetry/instrumentation": { + "version": "0.203.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz", + "integrity": "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==", + "dependencies": { + "@opentelemetry/api-logs": "0.203.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "packages/instrumentation-browser-navigation/node_modules/@opentelemetry/resources": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", + "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", + "dev": true, + "dependencies": { + "@opentelemetry/core": "2.0.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "packages/instrumentation-browser-navigation/node_modules/@opentelemetry/sdk-logs": { + "version": "0.203.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.203.0.tgz", + "integrity": "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==", + "dev": true, + "dependencies": { + "@opentelemetry/api-logs": "0.203.0", + "@opentelemetry/core": "2.0.1", + "@opentelemetry/resources": "2.0.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "packages/instrumentation-browser-navigation/node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "packages/instrumentation-browser-navigation/node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.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..c9c5fd837b --- /dev/null +++ b/packages/instrumentation-browser-navigation/package.json @@ -0,0 +1,61 @@ +{ + "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" + }, + "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..e30a5da717 --- /dev/null +++ b/packages/instrumentation-browser-navigation/src/instrumentation.ts @@ -0,0 +1,448 @@ +/* + * 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 { PACKAGE_NAME, PACKAGE_VERSION } from './version'; +import { + BrowserNavigationInstrumentationConfig, + NavigationType, + ApplyCustomLogRecordDataFunction, +} from './types'; + +/** + * This class represents a browser navigation instrumentation plugin + */ +export const EVENT_NAME = 'browser.navigation'; +export const ATTR_URL_FULL = 'url.full'; +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; + private _onLoadHandler?: () => void; + private _onPopStateHandler?: (ev: PopStateEvent) => void; + private _onNavigateHandler?: (ev: Event) => void; + private _lastUrl: string = location.href; + + /** + * + * @param config + */ + constructor(config: BrowserNavigationInstrumentationConfig) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this.applyCustomLogRecordData = config?.applyCustomLogRecordData; + } + + 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 === 'navigate' && navigationEvent?.destination?.url + ? navigationEvent.destination.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() { + // Ensure previous handler is removed before adding a new one + if (this._onLoadHandler) { + document.removeEventListener('DOMContentLoaded', this._onLoadHandler); + } + this._onLoadHandler = this._onHardNavigation.bind(this); + 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('navigate', this._onNavigateHandler); + this._onNavigateHandler = undefined; + } + this._onNavigateHandler = (event: any) => { + this._onSoftNavigation('navigate', event); + }; + navigationApi.addEventListener('navigate', 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?.( + 'navigate', + this._onNavigateHandler + ); + } catch { + // Ignore errors when removing Navigation API listeners + } + this._onNavigateHandler = undefined; + } + } + + /** + * 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 _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; + } + } + + private _determineSameDocument( + changeState?: string | null, + navigationEvent?: any, + fromUrl?: string, + toUrl?: string + ): boolean { + // For Navigation API events, use the sameDocument property if available + if ( + changeState === 'navigate' && + navigationEvent?.destination?.sameDocument !== undefined + ) { + return navigationEvent.destination.sameDocument; + } + + // 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 events, use the hashChange property if available + if ( + changeState === 'navigate' && + navigationEvent?.hashChange !== undefined + ) { + return navigationEvent.hashChange; + } + + // For all other cases, determine based on URL comparison + if (fromUrl && toUrl) { + return this._isHashChange(fromUrl, toUrl); + } + + return false; + } + + /** + * Sanitizes URL according to OpenTelemetry specification: + * - Redacts credentials (username:password) + * - Redacts sensitive query parameters + * - Preserves fragment when available + */ + private _sanitizeUrl(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; + } + } + + private _mapChangeStateToType( + changeState?: string | null, + navigationEvent?: any + ): NavigationType | undefined { + // For Navigation API events, check if it's a hash change first + if (changeState === 'navigate' && navigationEvent?.hashChange) { + // Hash changes are always considered 'push' operations semantically + return 'push'; + } + + // For Navigation API events, determine type based on event properties + if (changeState === 'navigate') { + // Check if this is a back/forward navigation (traverse) + if (navigationEvent?.navigationType === 'traverse') { + return 'traverse'; + } + + // Check if this is a replace operation + if (navigationEvent?.navigationType === 'replace') { + return 'replace'; + } + + // Check if this is a reload + if (navigationEvent?.navigationType === 'reload') { + return 'reload'; + } + + // Default to 'push' for new navigations (link clicks, programmatic navigation) + 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..4180f1119a --- /dev/null +++ b/packages/instrumentation-browser-navigation/src/types.ts @@ -0,0 +1,33 @@ +/* + * 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; +} + +export interface ApplyCustomLogRecordDataFunction { + (logRecord: LogRecord): void; +} + +export type NavigationType = 'push' | 'replace' | 'reload' | 'traverse'; 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..2124787f46 --- /dev/null +++ b/packages/instrumentation-browser-navigation/test/navigation.test.ts @@ -0,0 +1,687 @@ +/* + * 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_URL_FULL, + ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT, + ATTR_BROWSER_NAVIGATION_HASH_CHANGE, + ATTR_BROWSER_NAVIGATION_HASH_TYPE, +} from '../src/instrumentation'; +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, + }); + + const spy = sandbox.spy(document, 'addEventListener'); + // instrumentation.enable(); + instrumentation.enable(); + + setTimeout(() => { + assert.ok(spy.calledOnce); + + document.dispatchEvent(new Event('DOMContentLoaded')); + + assert.strictEqual(exporter.getFinishedLogRecords().length, 1); + + const navLogRecord = + exporter.getFinishedLogRecords()[0] 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(); + }); + }); + + 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, + applyCustomLogRecordData: logRecord => { + if (!logRecord.attributes) { + (logRecord as any).attributes = {}; + } + (logRecord.attributes as any)['vp.startTime'] = vpStartTime; + }, + }); + + history.pushState({}, '', '/dummy1.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 + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ATTR_BROWSER_NAVIGATION_HASH_TYPE], + 'push' + ); + assert.strictEqual( + (navLogRecord.attributes as any)['vp.startTime'], + vpStartTime + ); + done(); + }); + + 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, + 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, + 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 navigate 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(); + + // Clear any existing records + exporter.reset(); + + // Use actual Navigation API if available + const navigation = (window as any).navigation; + let navigateHandler: ((event: any) => void) | null = null; + + const cleanup = () => { + if (navigateHandler && navigation) { + navigation.removeEventListener('navigate', navigateHandler); + } + }; + + navigateHandler = (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 + ], + event.destination?.sameDocument ?? true + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_CHANGE + ], + event.hashChange ?? false + ); + cleanup(); + done(); + } + }, 10); + }; + + navigation.addEventListener('navigate', navigateHandler); + + // Trigger a navigation that should fire the navigate event + try { + // Use navigation.navigate() with relative URL to avoid page reload + if (navigation.navigate) { + // Prevent actual navigation to avoid page reload + const interceptHandler = (event: any) => { + event.preventDefault(); + }; + navigation.addEventListener('navigate', interceptHandler, { + once: true, + }); + navigation.navigate('?test=nav-api'); + } else { + history.pushState({}, '', '?test=nav-api'); + // Manually trigger if navigate() not available + 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); + } + } 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); + }); + + it('should export LogRecord with Navigation API hashChange property', 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(); + + // Clear any existing records + exporter.reset(); + + const navigation = (window as any).navigation; + let navigateHandler: ((event: any) => void) | null = null; + + const cleanup = () => { + if (navigateHandler && navigation) { + navigation.removeEventListener('navigate', navigateHandler); + } + }; + + navigateHandler = (event: any) => { + // Check if this is a hash change navigation + if (event.hashChange) { + 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 + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_CHANGE + ], + true + ); + assert.strictEqual( + (navLogRecord.attributes as any)[ + ATTR_BROWSER_NAVIGATION_HASH_TYPE + ], + 'push' + ); + cleanup(); + done(); + } + }, 10); + } + }; + + navigation.addEventListener('navigate', navigateHandler); + + // Trigger a hash navigation + try { + if (navigation.navigate) { + // Prevent actual navigation to avoid page reload + const interceptHandler = (event: any) => { + event.preventDefault(); + }; + navigation.addEventListener('navigate', interceptHandler, { + once: true, + }); + navigation.navigate('#section1'); + } else { + // Fallback to traditional hash change + location.hash = '#section1'; + 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_HASH_CHANGE + ], + true + ); + cleanup(); + done(); + } + }, 50); + } + } catch (_error) { + // Fallback to traditional hash change + location.hash = '#section1'; + 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_HASH_CHANGE + ], + true + ); + cleanup(); + done(); + } + }, 50); + } + + // Cleanup timeout + setTimeout(() => { + cleanup(); + done(new Error('Test timeout - Hash change navigation not detected')); + }, 1000); + }); + + it('should sanitize URLs with credentials', done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + }); + + // Test the sanitization method directly + const testUrl = + 'https://user:password@example.com/path?api_key=secret123&normal=value'; + const sanitized = (instrumentation as any)._sanitizeUrl(testUrl); + + assert.ok( + sanitized.includes('REDACTED:REDACTED@'), + 'Should redact credentials' + ); + assert.ok( + sanitized.includes('api_key=REDACTED'), + 'Should redact sensitive query params' + ); + assert.ok( + sanitized.includes('normal=value'), + 'Should preserve normal query params' + ); + done(); + }); + + it('should work with Navigation API disabled', done => { + instrumentation = new BrowserNavigationInstrumentation({ + enabled: true, + useNavigationApiIfAvailable: false, + }); + 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/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": {} } }