Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions demo/src/client/pages/Features/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export function Events() {
faro.api.setView({ name: `randomly-changed-view-${Math.random()}` });
};

const softNavigation = () => {
const url = new URL(window.location.href);
url.searchParams.set('value', Math.floor(Math.random() * 10).toString());
window.history.pushState(null, '', url.toString());
};

return (
<>
<h3>Events</h3>
Expand All @@ -43,6 +49,9 @@ export function Events() {
<Button data-cy="btn-change-view" onClick={changeView}>
Change view
</Button>
<Button data-cy="btn-soft-navigation" onClick={softNavigation}>
Soft navigation
</Button>
</ButtonGroup>
</>
);
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export interface Config<P = APIEvent> {
* If you want to disable only some levels set captureConsoleDisabledLevels: [LogLevel.DEBUG, LogLevel.TRACE];
*/
disabledLevels?: LogLevel[];

/*
* By default, Faro sends an error for console.error calls. If you want to send a log instead, set this to true.
*/
Expand All @@ -217,6 +218,22 @@ export interface Config<P = APIEvent> {
errorSerializer?: LogArgsSerializer;
};

/**
* Configuration for the navigation instrumentation
*/
navigationInstrumentation?: {
/**
* Report all URL changes, not just user initiated ones (default: false)
*/
reportAllChanges?: boolean;

/**
* Use history API rather than the experimental navigation API (even when available)
* for navigation tracking (default: false)
*/
useHistoryApi?: boolean;
};

/**
* Configuration for the page tracking
*/
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ export class ReactIntegration extends BaseInstrumentation {
name = '@grafana/faro-react';
version = VERSION;

private routerInstrumented: boolean = false;

constructor(private options: ReactIntegrationConfig = {}) {
super();
}

isRouterInstrumented(): boolean {
return this.routerInstrumented;
}

initialize(): void {
setDependencies(this.internalLogger, this.api);
initializeReactRouterInstrumentation(this.options);

this.routerInstrumented = this.options.router !== undefined;
}
}
2 changes: 2 additions & 0 deletions packages/web-sdk/src/config/getWebInstrumentations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Instrumentation } from '@grafana/faro-core';
import {
ConsoleInstrumentation,
ErrorsInstrumentation,
NavigationInstrumentation,
PerformanceInstrumentation,
SessionInstrumentation,
UserActionInstrumentation,
Expand All @@ -19,6 +20,7 @@ export function getWebInstrumentations(options: GetWebInstrumentationsOptions =
new WebVitalsInstrumentation(),
new SessionInstrumentation(),
new ViewInstrumentation(),
new NavigationInstrumentation(),
];

if (options.enablePerformanceInstrumentation !== false) {
Expand Down
2 changes: 2 additions & 0 deletions packages/web-sdk/src/instrumentations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ export {
export { PerformanceInstrumentation } from './performance';

export { UserActionInstrumentation, userActionDataAttribute, startUserAction } from './userActions';

export { NavigationInstrumentation } from './navigation';
9 changes: 9 additions & 0 deletions packages/web-sdk/src/instrumentations/navigation/consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const NAVIGATION_EVENT_TYPE = 'navigation';
export const NAVIGATION_PUSH_STATE = 'faro.navigation.pushState';
export const NAVIGATION_REPLACE_STATE = 'faro.navigation.replaceState';
export const NAVIGATION_FORWARD = 'faro.navigation.forward';
export const NAVIGATION_BACK = 'faro.navigation.back';
export const NAVIGATION_GO = 'faro.navigation.go';
export const NAVIGATION_POPSTATE = 'faro.navigation.popstate';
export const NAVIGATION_HASHCHANGE = 'faro.navigation.hashchange';
export const NAVIGATION_NAVIGATE = 'faro.navigation';
1 change: 1 addition & 0 deletions packages/web-sdk/src/instrumentations/navigation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NavigationInstrumentation } from './instrumentation';
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import {
NAVIGATION_BACK,
NAVIGATION_EVENT_TYPE,
NAVIGATION_FORWARD,
NAVIGATION_GO,
NAVIGATION_HASHCHANGE,
NAVIGATION_NAVIGATE,
NAVIGATION_POPSTATE,
NAVIGATION_PUSH_STATE,
NAVIGATION_REPLACE_STATE,
} from './consts';
import { NavigationInstrumentation } from './instrumentation';

describe('NavigationInstrumentation', () => {
let originalHistory: History;
let originalNavigation: any;
let pushEvent: jest.Mock;
let instrumentation: NavigationInstrumentation;

beforeEach(() => {
// Mock window.history
originalHistory = window.history;
(window as any).history = {
...originalHistory,
pushState: jest.fn(),
replaceState: jest.fn(),
forward: jest.fn(),
back: jest.fn(),
go: jest.fn(),
};

// Mock window.navigation (not widely supported)
originalNavigation = window.navigation;
(window as any).navigation = undefined;

// Mock API
pushEvent = jest.fn();
instrumentation = new NavigationInstrumentation();
(instrumentation as any).api = { pushEvent };
(instrumentation as any).internalLogger = { info: jest.fn() };
});

afterEach(() => {
(window as any).history = originalHistory;
(window as any).navigation = originalNavigation;
jest.restoreAllMocks();
});

describe('instrumentHistory', () => {
it('should push event on pushState', () => {
instrumentation.initialize();
window.history.pushState({ foo: 'bar' }, 'title', '/new-url');
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_PUSH_STATE,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
state: { foo: 'bar' },
title: 'title',
fromUrl: expect.any(String),
toUrl: '/new-url',
})
);
});

it('should push event on replaceState', () => {
instrumentation.initialize();
window.history.replaceState({ foo: 'baz' }, 'title2', '/replace-url');
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_REPLACE_STATE,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
state: { foo: 'baz' },
title: 'title2',
fromUrl: expect.any(String),
toUrl: '/replace-url',
})
);
});

it('should push event on forward', () => {
instrumentation.initialize();
window.history.forward();
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_FORWARD,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
})
);
});

it('should push event on back', () => {
instrumentation.initialize();
window.history.back();
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_BACK,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
})
);
});

it('should push event on go', () => {
instrumentation.initialize();
window.history.go(2);
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_GO,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
delta: '2',
})
);
});

it('should push event on popstate', () => {
instrumentation.initialize();
window.dispatchEvent(new PopStateEvent('popstate'));
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_POPSTATE,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
fromUrl: expect.any(String),
toUrl: expect.any(String),
})
);
});

it('should push event on hashchange', () => {
instrumentation.initialize();
const event = new HashChangeEvent('hashchange', {
oldURL: 'http://localhost/#old',
newURL: 'http://localhost/#new',
});
window.dispatchEvent(event);
expect(pushEvent).toHaveBeenCalledWith(
NAVIGATION_HASHCHANGE,
expect.objectContaining({
type: NAVIGATION_EVENT_TYPE,
fromUrl: 'http://localhost/#old',
toUrl: 'http://localhost/#new',
fromHash: '',
toHash: '#new',
})
);
});
});

describe('instrumentNavigation', () => {
beforeEach(() => {
// Provide a mock navigation API
(window as any).navigation = {
addEventListener: jest.fn((_, cb) => {
// Save the callback for manual invocation
(window as any)._navigationCallback = cb;
}),
};
instrumentation = new NavigationInstrumentation();
(instrumentation as any).api = { pushEvent };
(instrumentation as any).internalLogger = { info: jest.fn() };
});

it('should push event on navigation.navigate', () => {
instrumentation.initialize();
const mockEvent = {
destination: { url: '/destination' },
navigationType: 'push',
userInitiated: true,
canIntercept: false,
signal: { aborted: false, reason: undefined },
hashChange: false,
formData: undefined,
};
(window as any)._navigationCallback(mockEvent);
expect(pushEvent).toHaveBeenCalledWith(
`${NAVIGATION_NAVIGATE}.push`,
expect.objectContaining({
type: `${NAVIGATION_NAVIGATE}.push`,
fromUrl: expect.any(String),
toUrl: '/destination',
navigationType: 'push',
userInitiated: 'true',
canIntercept: 'false',
signal: expect.any(String),
hashChange: 'false',
formData: '',
})
);
});
});
});
Loading