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
+
+
+
+
+
+
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:
+
+
+ ✅ Navigation API Support: Modern browser navigation
+ events
+
+
+ ✅ History API Tracking: pushState, replaceState,
+ popstate events
+
+
+ ✅ Hash Change Detection: Fragment navigation
+ tracking
+
+
+
+
+
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.
-
+
-
+
+
+
+ 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": {}
}
}