Skip to content

Commit

Permalink
feat: adding ability to track all changes for web vitals (#981)
Browse files Browse the repository at this point in the history
  • Loading branch information
eskirk authored Feb 20, 2025
1 parent 76b8578 commit 3bab705
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 108 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

## Next

- Feature (`@grafana/faro-web-sdk`): Provide a `webVitalsInstrumentation.reportAllChanges` option to report
all changes for web vitals (#981)
- feat (`@grafana/faro-web-sdk`): Enhance user meta properties to align with OTEL semantic
conventions for user attributes (#990)

- Chore (`@grafana/faro-web-tracing`): Add user attributes to spans (#990)

## 1.13.3
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,29 @@ export interface Config<P = APIEvent> {
*/
trackWebVitalsAttribution?: boolean;

/**
* Configuration for the web vitals instrumentation
*/
webVitalsInstrumentation?: {
/**
* Report all changes for web vitals (default: false)
*
* In most cases, you only want the callback function to be called when the metric is ready to be reported.
* However, it is possible to report every change (e.g. each larger layout shift as it happens)
* by setting reportAllChanges to true.
*
* This can be useful when debugging, but in general using reportAllChanges is not needed (or recommended)
* for measuring these metrics in production.
*/
reportAllChanges?: boolean;
};

/**
* Configuration for the console instrumentation
*/
consoleInstrumentation?: {
/**
* Configure what console levels should be captured by Faro. By default the follwoing levels
* Configure what console levels should be captured by Faro. By default the following levels
* are disabled: console.debug, console.trace, console.log
*
* If you want to collect all levels set captureConsoleDisabledLevels: [];
Expand Down
16 changes: 16 additions & 0 deletions packages/web-sdk/src/config/makeCoreConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ describe('config', () => {
expect(config?.ignoreUrls).toEqual([/\/collect(?:\/[\w]*)?$/]);
});

it('enables web vitals feature when trackWebVitalsAttribution is true', () => {
const browserConfig = {
url: 'http://example.com/my-collector',
app: {},
trackWebVitalsAttribution: true,
webVitalsInstrumentation: {
reportAllChanges: true,
},
};
const config = makeCoreConfig(browserConfig);

expect(config).toBeTruthy();
expect(config?.trackWebVitalsAttribution).toBe(true);
expect(config?.webVitalsInstrumentation?.reportAllChanges).toBe(true);
});

it('merges configured urls with default URLs into ignoreUrls list', () => {
const browserConfig = {
url: 'http://example.com/my-collector',
Expand Down
3 changes: 2 additions & 1 deletion packages/web-sdk/src/config/makeCoreConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export function makeCoreConfig(browserConfig: BrowserConfig): Config {
user,
view,
geoLocationTracking,

// properties with default values
dedupe = true,
eventDomain = defaultEventDomain,
Expand All @@ -68,6 +67,7 @@ export function makeCoreConfig(browserConfig: BrowserConfig): Config {
paused = false,
preventGlobalExposure = false,
unpatchedConsole = defaultUnpatchedConsole,
webVitalsInstrumentation,
}: BrowserConfig = browserConfig;

return {
Expand Down Expand Up @@ -103,6 +103,7 @@ export function makeCoreConfig(browserConfig: BrowserConfig): Config {
trackResources,
trackWebVitalsAttribution,
consoleInstrumentation,
webVitalsInstrumentation,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ export class WebVitalsInstrumentation extends BaseInstrumentation {
}

private intializeWebVitalsInstrumentation() {
if (this.config.trackWebVitalsAttribution) {
return new WebVitalsWithAttribution(this.api.pushMeasurement);
if (this.config?.trackWebVitalsAttribution) {
return new WebVitalsWithAttribution(this.api.pushMeasurement, this.config.webVitalsInstrumentation);
}
return new WebVitalsBasic(this.api.pushMeasurement);
return new WebVitalsBasic(this.api.pushMeasurement, this.config.webVitalsInstrumentation);
}
}
26 changes: 16 additions & 10 deletions packages/web-sdk/src/instrumentations/webVitals/webVitalsBasic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals';

import type { MeasurementsAPI } from '@grafana/faro-core';
import type { Config, MeasurementsAPI } from '@grafana/faro-core';

export class WebVitalsBasic {
static mapping = {
Expand All @@ -12,19 +12,25 @@ export class WebVitalsBasic {
ttfb: onTTFB,
};

constructor(private pushMeasurement: MeasurementsAPI['pushMeasurement']) {}
constructor(
private pushMeasurement: MeasurementsAPI['pushMeasurement'],
private webVitalConfig?: Config['webVitalsInstrumentation']
) {}

initialize(): void {
Object.entries(WebVitalsBasic.mapping).forEach(([indicator, executor]) => {
executor((metric) => {
this.pushMeasurement({
type: 'web-vitals',
executor(
(metric) => {
this.pushMeasurement({
type: 'web-vitals',

values: {
[indicator]: metric.value,
},
});
});
values: {
[indicator]: metric.value,
},
});
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals/attributio
import type { Metric } from 'web-vitals/attribution';

import { unknownString } from '@grafana/faro-core';
import type { MeasurementEvent, MeasurementsAPI, PushMeasurementOptions } from '@grafana/faro-core';
import type { Config, MeasurementEvent, MeasurementsAPI, PushMeasurementOptions } from '@grafana/faro-core';

import { getItem, webStorageType } from '../../utils';
import { NAVIGATION_ID_STORAGE_KEY } from '../instrumentationConstants';
Expand All @@ -16,7 +16,10 @@ const loadStateKey = 'load_state';
const timeToFirstByteKey = 'time_to_first_byte';

export class WebVitalsWithAttribution {
constructor(private corePushMeasurement: MeasurementsAPI['pushMeasurement']) {}
constructor(
private corePushMeasurement: MeasurementsAPI['pushMeasurement'],
private webVitalConfig?: Config['webVitalsInstrumentation']
) {}

initialize(): void {
this.measureCLS();
Expand All @@ -28,114 +31,132 @@ export class WebVitalsWithAttribution {
}

private measureCLS(): void {
onCLS((metric) => {
const { loadState, largestShiftValue, largestShiftTime, largestShiftTarget } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'largest_shift_value', largestShiftValue);
this.addIfPresent(values, 'largest_shift_time', largestShiftTime);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, loadStateKey, loadState);
this.addIfPresent(context, 'largest_shift_target', largestShiftTarget);

this.pushMeasurement(values, context);
});
onCLS(
(metric) => {
const { loadState, largestShiftValue, largestShiftTime, largestShiftTarget } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'largest_shift_value', largestShiftValue);
this.addIfPresent(values, 'largest_shift_time', largestShiftTime);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, loadStateKey, loadState);
this.addIfPresent(context, 'largest_shift_target', largestShiftTarget);

this.pushMeasurement(values, context);
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
}

private measureFCP(): void {
onFCP((metric) => {
const { firstByteToFCP, timeToFirstByte, loadState } = metric.attribution;
onFCP(
(metric) => {
const { firstByteToFCP, timeToFirstByte, loadState } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'first_byte_to_fcp', firstByteToFCP);
this.addIfPresent(values, timeToFirstByteKey, timeToFirstByte);
const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'first_byte_to_fcp', firstByteToFCP);
this.addIfPresent(values, timeToFirstByteKey, timeToFirstByte);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, loadStateKey, loadState);
const context = this.buildInitialContext(metric);
this.addIfPresent(context, loadStateKey, loadState);

this.pushMeasurement(values, context);
});
this.pushMeasurement(values, context);
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
}

private measureFID(): void {
onFID((metric) => {
const { eventTime, eventTarget, eventType, loadState } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'event_time', eventTime);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, 'event_target', eventTarget);
this.addIfPresent(context, 'event_type', eventType);
this.addIfPresent(context, loadStateKey, loadState);

this.pushMeasurement(values, context);
});
onFID(
(metric) => {
const { eventTime, eventTarget, eventType, loadState } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'event_time', eventTime);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, 'event_target', eventTarget);
this.addIfPresent(context, 'event_type', eventType);
this.addIfPresent(context, loadStateKey, loadState);

this.pushMeasurement(values, context);
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
}

private measureINP(): void {
onINP((metric) => {
const {
interactionTime,
presentationDelay,
inputDelay,
processingDuration,
nextPaintTime,
loadState,
interactionTarget,
interactionType,
} = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'interaction_time', interactionTime);
this.addIfPresent(values, 'presentation_delay', presentationDelay);
this.addIfPresent(values, 'input_delay', inputDelay);
this.addIfPresent(values, 'processing_duration', processingDuration);
this.addIfPresent(values, 'next_paint_time', nextPaintTime);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, loadStateKey, loadState);
this.addIfPresent(context, 'interaction_target', interactionTarget);
this.addIfPresent(context, 'interaction_type', interactionType);

this.pushMeasurement(values, context);
});
onINP(
(metric) => {
const {
interactionTime,
presentationDelay,
inputDelay,
processingDuration,
nextPaintTime,
loadState,
interactionTarget,
interactionType,
} = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'interaction_time', interactionTime);
this.addIfPresent(values, 'presentation_delay', presentationDelay);
this.addIfPresent(values, 'input_delay', inputDelay);
this.addIfPresent(values, 'processing_duration', processingDuration);
this.addIfPresent(values, 'next_paint_time', nextPaintTime);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, loadStateKey, loadState);
this.addIfPresent(context, 'interaction_target', interactionTarget);
this.addIfPresent(context, 'interaction_type', interactionType);

this.pushMeasurement(values, context);
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
}

private measureLCP(): void {
onLCP((metric) => {
const { elementRenderDelay, resourceLoadDelay, resourceLoadDuration, timeToFirstByte, element } =
metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'element_render_delay', elementRenderDelay);
this.addIfPresent(values, 'resource_load_delay', resourceLoadDelay);
this.addIfPresent(values, 'resource_load_duration', resourceLoadDuration);
this.addIfPresent(values, timeToFirstByteKey, timeToFirstByte);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, 'element', element);

this.pushMeasurement(values, context);
});
onLCP(
(metric) => {
const { elementRenderDelay, resourceLoadDelay, resourceLoadDuration, timeToFirstByte, element } =
metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'element_render_delay', elementRenderDelay);
this.addIfPresent(values, 'resource_load_delay', resourceLoadDelay);
this.addIfPresent(values, 'resource_load_duration', resourceLoadDuration);
this.addIfPresent(values, timeToFirstByteKey, timeToFirstByte);

const context = this.buildInitialContext(metric);
this.addIfPresent(context, 'element', element);

this.pushMeasurement(values, context);
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
}

private measureTTFB(): void {
onTTFB((metric) => {
const { dnsDuration, connectionDuration, requestDuration, waitingDuration, cacheDuration } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'dns_duration', dnsDuration);
this.addIfPresent(values, 'connection_duration', connectionDuration);
this.addIfPresent(values, 'request_duration', requestDuration);
this.addIfPresent(values, 'waiting_duration', waitingDuration);
this.addIfPresent(values, 'cache_duration', cacheDuration);

const context = this.buildInitialContext(metric);

this.pushMeasurement(values, context);
});
onTTFB(
(metric) => {
const { dnsDuration, connectionDuration, requestDuration, waitingDuration, cacheDuration } = metric.attribution;

const values = this.buildInitialValues(metric);
this.addIfPresent(values, 'dns_duration', dnsDuration);
this.addIfPresent(values, 'connection_duration', connectionDuration);
this.addIfPresent(values, 'request_duration', requestDuration);
this.addIfPresent(values, 'waiting_duration', waitingDuration);
this.addIfPresent(values, 'cache_duration', cacheDuration);

const context = this.buildInitialContext(metric);

this.pushMeasurement(values, context);
},
{ reportAllChanges: this.webVitalConfig?.reportAllChanges }
);
}

private buildInitialValues(metric: Metric): Values {
Expand Down

0 comments on commit 3bab705

Please sign in to comment.