1616
1717import { InstrumentationBase , isWrapped } from '@opentelemetry/instrumentation' ;
1818import { LogRecord } from '@opentelemetry/api-logs' ;
19+ import { ATTR_URL_FULL } from '@opentelemetry/semantic-conventions' ;
1920import { PACKAGE_NAME , PACKAGE_VERSION } from './version' ;
2021import {
2122 BrowserNavigationInstrumentationConfig ,
2223 NavigationType ,
2324 ApplyCustomLogRecordDataFunction ,
25+ SanitizeUrlFunction ,
2426} from './types' ;
27+ import { isHashChange , defaultSanitizeUrl } from './utils' ;
2528
2629/**
2730 * This class represents a browser navigation instrumentation plugin
2831 */
2932export const EVENT_NAME = 'browser.navigation' ;
30- export const ATTR_URL_FULL = 'url.full' ;
3133export const ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT =
3234 'browser.navigation.same_document' ;
3335export const ATTR_BROWSER_NAVIGATION_HASH_CHANGE =
@@ -37,10 +39,12 @@ export const ATTR_BROWSER_NAVIGATION_HASH_TYPE = 'browser.navigation.type';
3739export class BrowserNavigationInstrumentation extends InstrumentationBase < BrowserNavigationInstrumentationConfig > {
3840 applyCustomLogRecordData : ApplyCustomLogRecordDataFunction | undefined =
3941 undefined ;
42+ sanitizeUrl : SanitizeUrlFunction = defaultSanitizeUrl ; // Initialize with default
4043 private _onLoadHandler ?: ( ) => void ;
4144 private _onPopStateHandler ?: ( ev : PopStateEvent ) => void ;
4245 private _onNavigateHandler ?: ( ev : Event ) => void ;
4346 private _lastUrl : string = location . href ;
47+ private _hasProcessedInitialLoad : boolean = false ;
4448
4549 /**
4650 *
@@ -49,6 +53,10 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
4953 constructor ( config : BrowserNavigationInstrumentationConfig ) {
5054 super ( PACKAGE_NAME , PACKAGE_VERSION , config ) ;
5155 this . applyCustomLogRecordData = config ?. applyCustomLogRecordData ;
56+ // Override default with custom sanitizer if provided
57+ if ( config ?. sanitizeUrl ) {
58+ this . sanitizeUrl = config . sanitizeUrl ;
59+ }
5260 }
5361
5462 init ( ) { }
@@ -60,7 +68,7 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
6068 const navLogRecord : LogRecord = {
6169 eventName : EVENT_NAME ,
6270 attributes : {
63- [ ATTR_URL_FULL ] : this . _sanitizeUrl ( document . documentURI ) ,
71+ [ ATTR_URL_FULL ] : this . sanitizeUrl ( document . documentURI ) ,
6472 [ ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT ] : false ,
6573 [ ATTR_BROWSER_NAVIGATION_HASH_CHANGE ] : false ,
6674 } ,
@@ -78,8 +86,8 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
7886 ) {
7987 const referrerUrl = this . _lastUrl ;
8088 const currentUrl =
81- changeState === 'navigate ' && navigationEvent ?. destination ?. url
82- ? navigationEvent . destination . url
89+ changeState === 'currententrychange ' && navigationEvent ?. target ?. currentEntry ?. url
90+ ? navigationEvent . target . currentEntry . url
8391 : location . href ;
8492
8593 if ( referrerUrl === currentUrl ) {
@@ -102,7 +110,7 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
102110 const navLogRecord : LogRecord = {
103111 eventName : EVENT_NAME ,
104112 attributes : {
105- [ ATTR_URL_FULL ] : this . _sanitizeUrl ( currentUrl ) ,
113+ [ ATTR_URL_FULL ] : this . sanitizeUrl ( currentUrl ) ,
106114 [ ATTR_BROWSER_NAVIGATION_SAME_DOCUMENT ] : sameDocument ,
107115 [ ATTR_BROWSER_NAVIGATION_HASH_CHANGE ] : hashChange ,
108116 ...( navType ? { [ ATTR_BROWSER_NAVIGATION_HASH_TYPE ] : navType } : { } ) ,
@@ -119,11 +127,24 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
119127 * executes callback {_onDOMContentLoaded } when the page is viewed
120128 */
121129 private _waitForPageLoad ( ) {
130+ // Check if document has already loaded completely
131+ if ( document . readyState === 'complete' && ! this . _hasProcessedInitialLoad ) {
132+ this . _hasProcessedInitialLoad = true ;
133+ // Use setTimeout to allow tests to reset exporter before this fires
134+ setTimeout ( ( ) => this . _onHardNavigation ( ) , 0 ) ;
135+ return ;
136+ }
137+
122138 // Ensure previous handler is removed before adding a new one
123139 if ( this . _onLoadHandler ) {
124140 document . removeEventListener ( 'DOMContentLoaded' , this . _onLoadHandler ) ;
125141 }
126- this . _onLoadHandler = this . _onHardNavigation . bind ( this ) ;
142+ this . _onLoadHandler = ( ) => {
143+ if ( ! this . _hasProcessedInitialLoad ) {
144+ this . _hasProcessedInitialLoad = true ;
145+ this . _onHardNavigation ( ) ;
146+ }
147+ } ;
127148 document . addEventListener ( 'DOMContentLoaded' , this . _onLoadHandler ) ;
128149 }
129150
@@ -147,13 +168,13 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
147168
148169 if ( navigationApi ) {
149170 if ( this . _onNavigateHandler ) {
150- navigationApi . removeEventListener ( 'navigate ' , this . _onNavigateHandler ) ;
171+ navigationApi . removeEventListener ( 'currententrychange ' , this . _onNavigateHandler ) ;
151172 this . _onNavigateHandler = undefined ;
152173 }
153174 this . _onNavigateHandler = ( event : any ) => {
154- this . _onSoftNavigation ( 'navigate ' , event ) ;
175+ this . _onSoftNavigation ( 'currententrychange ' , event ) ;
155176 } ;
156- navigationApi . addEventListener ( 'navigate ' , this . _onNavigateHandler ) ;
177+ navigationApi . addEventListener ( 'currententrychange ' , this . _onNavigateHandler ) ;
157178 } else {
158179 if ( this . _onPopStateHandler ) {
159180 window . removeEventListener ( 'popstate' , this . _onPopStateHandler ) ;
@@ -183,14 +204,16 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
183204 try {
184205 const navigationApi = ( window as any ) . navigation as EventTarget ;
185206 navigationApi ?. removeEventListener ?.(
186- 'navigate ' ,
207+ 'currententrychange ' ,
187208 this . _onNavigateHandler
188209 ) ;
189210 } catch {
190211 // Ignore errors when removing Navigation API listeners
191212 }
192213 this . _onNavigateHandler = undefined ;
193214 }
215+ // Reset the initial load flag so it can be processed again if re-enabled
216+ this . _hasProcessedInitialLoad = false ;
194217 }
195218
196219 /**
@@ -243,53 +266,28 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
243266 }
244267 }
245268
246- private _isHashChange ( fromUrl : string , toUrl : string ) : boolean {
247- try {
248- const a = new URL ( fromUrl , window . location . origin ) ;
249- const b = new URL ( toUrl , window . location . origin ) ;
250- // Only consider it a hash change if:
251- // 1. Base URL (origin + pathname + search) is identical
252- // 2. Both URLs have hashes and they're different, OR we're adding a hash
253- const sameBase =
254- a . origin === b . origin &&
255- a . pathname === b . pathname &&
256- a . search === b . search ;
257- const fromHasHash = a . hash !== '' ;
258- const toHasHash = b . hash !== '' ;
259- const hashesAreDifferent = a . hash !== b . hash ;
260-
261- return (
262- sameBase &&
263- hashesAreDifferent &&
264- ( ( fromHasHash && toHasHash ) || ( ! fromHasHash && toHasHash ) )
265- ) ;
266- } catch {
267- // Fallback: check if base URLs are identical and we're changing/adding hash (not removing)
268- const fromBase = fromUrl . split ( '#' ) [ 0 ] ;
269- const toBase = toUrl . split ( '#' ) [ 0 ] ;
270- const fromHash = fromUrl . split ( '#' ) [ 1 ] || '' ;
271- const toHash = toUrl . split ( '#' ) [ 1 ] || '' ;
272-
273- const sameBase = fromBase === toBase ;
274- const hashesAreDifferent = fromHash !== toHash ;
275- const notRemovingHash = toHash !== '' ; // Only true if we're not removing the hash
276-
277- return sameBase && hashesAreDifferent && notRemovingHash ;
278- }
279- }
280269
281270 private _determineSameDocument (
282271 changeState ?: string | null ,
283272 navigationEvent ?: any ,
284273 fromUrl ?: string ,
285274 toUrl ?: string
286275 ) : boolean {
287- // For Navigation API events, use the sameDocument property if available
288- if (
289- changeState === 'navigate' &&
290- navigationEvent ?. destination ?. sameDocument !== undefined
291- ) {
292- return navigationEvent . destination . sameDocument ;
276+ // For Navigation API currententrychange events
277+ if ( changeState === 'currententrychange' ) {
278+ // For currententrychange, we can check if the navigation was same-document
279+ // by comparing origins or checking if it's a SPA navigation
280+ if ( fromUrl && toUrl ) {
281+ try {
282+ const fromURL = new URL ( fromUrl ) ;
283+ const toURL = new URL ( toUrl ) ;
284+ return fromURL . origin === toURL . origin ;
285+ } catch {
286+ return true ; // Fallback to same document
287+ }
288+ }
289+ // Default to true for same-document navigations in SPAs
290+ return true ;
293291 }
294292
295293 // For other navigation types, determine based on URL comparison
@@ -321,110 +319,47 @@ export class BrowserNavigationInstrumentation extends InstrumentationBase<Browse
321319 fromUrl ?: string ,
322320 toUrl ?: string
323321 ) : boolean {
324- // For Navigation API events, use the hashChange property if available
325- if (
326- changeState === 'navigate' &&
327- navigationEvent ?. hashChange !== undefined
328- ) {
329- return navigationEvent . hashChange ;
322+ // For Navigation API currententrychange events, determine based on URL comparison
323+ if ( changeState === 'currententrychange' ) {
324+ if ( fromUrl && toUrl ) {
325+ return isHashChange ( fromUrl , toUrl ) ;
326+ }
327+ return false ;
330328 }
331329
332330 // For all other cases, determine based on URL comparison
333331 if ( fromUrl && toUrl ) {
334- return this . _isHashChange ( fromUrl , toUrl ) ;
332+ return isHashChange ( fromUrl , toUrl ) ;
335333 }
336334
337335 return false ;
338336 }
339337
340- /**
341- * Sanitizes URL according to OpenTelemetry specification:
342- * - Redacts credentials (username:password)
343- * - Redacts sensitive query parameters
344- * - Preserves fragment when available
345- */
346- private _sanitizeUrl ( url : string ) : string {
347- const sensitiveParams = [
348- 'password' ,
349- 'passwd' ,
350- 'secret' ,
351- 'api_key' ,
352- 'apikey' ,
353- 'auth' ,
354- 'authorization' ,
355- 'token' ,
356- 'access_token' ,
357- 'refresh_token' ,
358- 'jwt' ,
359- 'session' ,
360- 'sessionid' ,
361- 'key' ,
362- 'private_key' ,
363- 'client_secret' ,
364- 'client_id' ,
365- 'signature' ,
366- 'hash' ,
367- ] ;
368- try {
369- const urlObj = new URL ( url ) ;
370-
371- // Redact credentials if present
372- if ( urlObj . username || urlObj . password ) {
373- urlObj . username = 'REDACTED' ;
374- urlObj . password = 'REDACTED' ;
375- }
376-
377- // Redact sensitive query parameters
378-
379- for ( const param of sensitiveParams ) {
380- if ( urlObj . searchParams . has ( param ) ) {
381- urlObj . searchParams . set ( param , 'REDACTED' ) ;
382- }
383- }
384-
385- return urlObj . toString ( ) ;
386- } catch {
387- // If URL parsing fails, redact credentials and sensitive query parameters
388- let sanitized = url . replace ( / \/ \/ [ ^ : ] + : [ ^ @ ] + @ / , '//REDACTED:REDACTED@' ) ;
389-
390- for ( const param of sensitiveParams ) {
391- // Match param=value or param%3Dvalue (URL encoded)
392- const regex = new RegExp ( `([?&]${ param } (?:%3D|=))[^&]*` , 'gi' ) ;
393- sanitized = sanitized . replace ( regex , '$1REDACTED' ) ;
394- }
395-
396- return sanitized ;
397- }
398- }
399338
400339 private _mapChangeStateToType (
401340 changeState ?: string | null ,
402341 navigationEvent ?: any
403342 ) : NavigationType | undefined {
404- // For Navigation API events, check if it's a hash change first
405- if ( changeState === 'navigate' && navigationEvent ?. hashChange ) {
406- // Hash changes are always considered 'push' operations semantically
407- return 'push' ;
408- }
409-
410- // For Navigation API events, determine type based on event properties
411- if ( changeState === 'navigate' ) {
412- // Check if this is a back/forward navigation (traverse)
413- if ( navigationEvent ?. navigationType === 'traverse' ) {
414- return 'traverse' ;
415- }
416-
417- // Check if this is a replace operation
418- if ( navigationEvent ?. navigationType === 'replace' ) {
419- return 'replace' ;
420- }
421-
422- // Check if this is a reload
423- if ( navigationEvent ?. navigationType === 'reload' ) {
424- return 'reload' ;
343+ // For Navigation API currententrychange events
344+ if ( changeState === 'currententrychange' ) {
345+ // First, try to get navigation type from the currententrychange event itself
346+ if ( navigationEvent ?. navigationType ) {
347+ const navType = navigationEvent . navigationType ;
348+ switch ( navType ) {
349+ case 'traverse' :
350+ return 'traverse' ;
351+ case 'replace' :
352+ return 'replace' ;
353+ case 'reload' :
354+ return 'reload' ;
355+ case 'push' :
356+ default :
357+ return 'push' ;
358+ }
425359 }
426-
427- // Default to 'push' for new navigations (link clicks, programmatic navigation)
360+
361+ // Fallback: For currententrychange events without type info, default to 'push'
362+ // Most programmatic navigations (history.pushState, link clicks) are 'push' operations
428363 return 'push' ;
429364 }
430365
0 commit comments