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