From ce16e339982c42cd7690b4b16aa220cf98766e23 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 7 Oct 2020 10:01:07 -0400 Subject: [PATCH] feat(rum): Add measurements support and web vitals (#2909) --- packages/tracing/src/browser/metrics.ts | 124 ++++++++---------- .../tracing/src/browser/web-vitals/README.md | 16 +++ .../tracing/src/browser/web-vitals/getFID.ts | 89 +++++++++++++ .../tracing/src/browser/web-vitals/getLCP.ts | 64 +++++++++ .../browser/web-vitals/lib/bindReporter.ts | 45 +++++++ .../web-vitals/lib/generateUniqueID.ts | 24 ++++ .../browser/web-vitals/lib/getFirstHidden.ts | 42 ++++++ .../src/browser/web-vitals/lib/initMetric.ts | 29 ++++ .../src/browser/web-vitals/lib/observe.ts | 41 ++++++ .../src/browser/web-vitals/lib/onHidden.ts | 53 ++++++++ .../src/browser/web-vitals/lib/whenInput.ts | 32 +++++ .../tracing/src/browser/web-vitals/types.ts | 45 +++++++ packages/tracing/src/transaction.ts | 24 +++- packages/types/src/event.ts | 2 + packages/types/src/index.ts | 1 + packages/types/src/transaction.ts | 2 + 16 files changed, 558 insertions(+), 75 deletions(-) create mode 100644 packages/tracing/src/browser/web-vitals/README.md create mode 100644 packages/tracing/src/browser/web-vitals/getFID.ts create mode 100644 packages/tracing/src/browser/web-vitals/getLCP.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/bindReporter.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/generateUniqueID.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/getFirstHidden.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/initMetric.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/observe.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/onHidden.ts create mode 100644 packages/tracing/src/browser/web-vitals/lib/whenInput.ts create mode 100644 packages/tracing/src/browser/web-vitals/types.ts diff --git a/packages/tracing/src/browser/metrics.ts b/packages/tracing/src/browser/metrics.ts index 82d3741d3b86..6760e0f6302d 100644 --- a/packages/tracing/src/browser/metrics.ts +++ b/packages/tracing/src/browser/metrics.ts @@ -1,17 +1,19 @@ /* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { SpanContext } from '@sentry/types'; +import { Measurements, SpanContext } from '@sentry/types'; import { browserPerformanceTimeOrigin, getGlobalObject, logger } from '@sentry/utils'; import { Span } from '../span'; import { Transaction } from '../transaction'; import { msToSec } from '../utils'; +import { getFID } from './web-vitals/getFID'; +import { getLCP } from './web-vitals/getLCP'; const global = getGlobalObject(); /** Class tracking metrics */ export class MetricsInstrumentation { - private _lcp: Record = {}; + private _measurements: Measurements = {}; private _performanceCursor: number = 0; @@ -22,6 +24,7 @@ export class MetricsInstrumentation { } this._trackLCP(); + this._trackFID(); } } @@ -34,16 +37,6 @@ export class MetricsInstrumentation { logger.log('[Tracing] Adding & adjusting spans using Performance API'); - // TODO(fixme): depending on the 'op' directly is brittle. - if (transaction.op === 'pageload') { - // Force any pending records to be dispatched. - this._forceLCP(); - if (this._lcp) { - // Set the last observed LCP score. - transaction.setData('_sentry_web_vitals', { LCP: this._lcp }); - } - } - const timeOrigin = msToSec(browserPerformanceTimeOrigin); let entryScriptSrc: string | undefined; @@ -85,6 +78,21 @@ export class MetricsInstrumentation { if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') { tracingInitMarkStartTime = startTimestamp; } + + // capture web vitals + + if (entry.name === 'first-paint') { + logger.log('[Measurements] Adding FP'); + this._measurements['fp'] = { value: entry.startTime }; + this._measurements['mark.fp'] = { value: startTimestamp }; + } + + if (entry.name === 'first-contentful-paint') { + logger.log('[Measurements] Adding FCP'); + this._measurements['fcp'] = { value: entry.startTime }; + this._measurements['mark.fcp'] = { value: startTimestamp }; + } + break; } case 'resource': { @@ -111,73 +119,45 @@ export class MetricsInstrumentation { } this._performanceCursor = Math.max(performance.getEntries().length - 1, 0); - } - private _forceLCP: () => void = () => { - /* No-op, replaced later if LCP API is available. */ - return; - }; + // Measurements are only available for pageload transactions + if (transaction.op === 'pageload') { + transaction.setMeasurements(this._measurements); + } + } /** Starts tracking the Largest Contentful Paint on the current page. */ private _trackLCP(): void { - // Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript. - // Use a try/catch instead of feature detecting `largest-contentful-paint` - // support, since some browsers throw when using the new `type` option. - // https://bugs.webkit.org/show_bug.cgi?id=209216 - try { - // Keep track of whether (and when) the page was first hidden, see: - // https://github.com/w3c/page-visibility/issues/29 - // NOTE: ideally this check would be performed in the document - // to avoid cases where the visibility state changes before this code runs. - let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity; - document.addEventListener( - 'visibilitychange', - event => { - firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp); - }, - { once: true }, - ); - - const updateLCP = (entry: PerformanceEntry): void => { - // Only include an LCP entry if the page wasn't hidden prior to - // the entry being dispatched. This typically happens when a page is - // loaded in a background tab. - if (entry.startTime < firstHiddenTime) { - // NOTE: the `startTime` value is a getter that returns the entry's - // `renderTime` value, if available, or its `loadTime` value otherwise. - // The `renderTime` value may not be available if the element is an image - // that's loaded cross-origin without the `Timing-Allow-Origin` header. - this._lcp = { - // @ts-ignore can't access id on entry - ...(entry.id && { elementId: entry.id }), - // @ts-ignore can't access id on entry - ...(entry.size && { elementSize: entry.size }), - value: entry.startTime, - }; - } - }; + getLCP(metric => { + const entry = metric.entries.pop(); - // Create a PerformanceObserver that calls `updateLCP` for each entry. - const po = new PerformanceObserver(entryList => { - entryList.getEntries().forEach(updateLCP); - }); + if (!entry) { + return; + } - // Observe entries of type `largest-contentful-paint`, including buffered entries, - // i.e. entries that occurred before calling `observe()` below. - po.observe({ - buffered: true, - // @ts-ignore type does not exist on obj - type: 'largest-contentful-paint', - }); + const timeOrigin = msToSec(performance.timeOrigin); + const startTime = msToSec(entry.startTime as number); + logger.log('[Measurements] Adding LCP'); + this._measurements['lcp'] = { value: metric.value }; + this._measurements['mark.lcp'] = { value: timeOrigin + startTime }; + }); + } - this._forceLCP = () => { - if (po.takeRecords) { - po.takeRecords().forEach(updateLCP); - } - }; - } catch (e) { - // Do nothing if the browser doesn't support this API. - } + /** Starts tracking the First Input Delay on the current page. */ + private _trackFID(): void { + getFID(metric => { + const entry = metric.entries.pop(); + + if (!entry) { + return; + } + + const timeOrigin = msToSec(performance.timeOrigin); + const startTime = msToSec(entry.startTime as number); + logger.log('[Measurements] Adding FID'); + this._measurements['fid'] = { value: metric.value }; + this._measurements['mark.fid'] = { value: timeOrigin + startTime }; + }); } } diff --git a/packages/tracing/src/browser/web-vitals/README.md b/packages/tracing/src/browser/web-vitals/README.md new file mode 100644 index 000000000000..e4bf6340d1d4 --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/README.md @@ -0,0 +1,16 @@ +# web-vitals + +> A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users. + +This was vendored from: https://github.com/GoogleChrome/web-vitals + +The commit SHA used is: [56c736b7c4e80f295bc8a98017671c95231fa225](https://github.com/GoogleChrome/web-vitals/tree/56c736b7c4e80f295bc8a98017671c95231fa225) + +Current vendored web vitals are: + +- LCP (Largest Contentful Paint) +- FID (First Input Delay) + +# License + +[Apache 2.0](https://github.com/GoogleChrome/web-vitals/blob/master/LICENSE) diff --git a/packages/tracing/src/browser/web-vitals/getFID.ts b/packages/tracing/src/browser/web-vitals/getFID.ts new file mode 100644 index 000000000000..2093c25b9fa4 --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/getFID.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Google LLC + * + * 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 { bindReporter } from './lib/bindReporter'; +import { getFirstHidden } from './lib/getFirstHidden'; +import { initMetric } from './lib/initMetric'; +import { observe, PerformanceEntryHandler } from './lib/observe'; +import { onHidden } from './lib/onHidden'; +import { ReportHandler } from './types'; + +interface FIDPolyfillCallback { + (value: number, event: Event): void; +} + +interface FIDPolyfill { + onFirstInputDelay: (onReport: FIDPolyfillCallback) => void; +} + +declare global { + interface Window { + perfMetrics: FIDPolyfill; + } +} + +// https://wicg.github.io/event-timing/#sec-performance-event-timing +interface PerformanceEventTiming extends PerformanceEntry { + processingStart: DOMHighResTimeStamp; + cancelable?: boolean; + target?: Element; +} + +export const getFID = (onReport: ReportHandler): void => { + const metric = initMetric('FID'); + const firstHidden = getFirstHidden(); + + const entryHandler = (entry: PerformanceEventTiming): void => { + // Only report if the page wasn't hidden prior to the first input. + if (entry.startTime < firstHidden.timeStamp) { + metric.value = entry.processingStart - entry.startTime; + metric.entries.push(entry); + metric.isFinal = true; + report(); + } + }; + + const po = observe('first-input', entryHandler as PerformanceEntryHandler); + const report = bindReporter(onReport, metric, po); + + if (po) { + onHidden(() => { + po.takeRecords().map(entryHandler as PerformanceEntryHandler); + po.disconnect(); + }, true); + } else { + if (window.perfMetrics && window.perfMetrics.onFirstInputDelay) { + window.perfMetrics.onFirstInputDelay((value: number, event: Event) => { + // Only report if the page wasn't hidden prior to the first input. + if (event.timeStamp < firstHidden.timeStamp) { + metric.value = value; + metric.isFinal = true; + metric.entries = [ + { + entryType: 'first-input', + name: event.type, + target: event.target, + cancelable: event.cancelable, + startTime: event.timeStamp, + processingStart: event.timeStamp + value, + } as PerformanceEventTiming, + ]; + report(); + } + }); + } + } +}; diff --git a/packages/tracing/src/browser/web-vitals/getLCP.ts b/packages/tracing/src/browser/web-vitals/getLCP.ts new file mode 100644 index 000000000000..e0fdd9c7cfde --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/getLCP.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Google LLC + * + * 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 { bindReporter } from './lib/bindReporter'; +import { getFirstHidden } from './lib/getFirstHidden'; +import { initMetric } from './lib/initMetric'; +import { observe, PerformanceEntryHandler } from './lib/observe'; +import { onHidden } from './lib/onHidden'; +import { whenInput } from './lib/whenInput'; +import { ReportHandler } from './types'; + +export const getLCP = (onReport: ReportHandler, reportAllChanges = false): void => { + const metric = initMetric('LCP'); + const firstHidden = getFirstHidden(); + + let report: ReturnType; + + const entryHandler = (entry: PerformanceEntry): void => { + // The startTime attribute returns the value of the renderTime if it is not 0, + // and the value of the loadTime otherwise. + const value = entry.startTime; + + // If the page was hidden prior to paint time of the entry, + // ignore it and mark the metric as final, otherwise add the entry. + if (value < firstHidden.timeStamp) { + metric.value = value; + metric.entries.push(entry); + } else { + metric.isFinal = true; + } + + report(); + }; + + const po = observe('largest-contentful-paint', entryHandler); + + if (po) { + report = bindReporter(onReport, metric, po, reportAllChanges); + + const onFinal = (): void => { + if (!metric.isFinal) { + po.takeRecords().map(entryHandler as PerformanceEntryHandler); + metric.isFinal = true; + report(); + } + }; + + void whenInput().then(onFinal); + onHidden(onFinal, true); + } +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/bindReporter.ts b/packages/tracing/src/browser/web-vitals/lib/bindReporter.ts new file mode 100644 index 000000000000..bfabaa607b52 --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/bindReporter.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Google LLC + * + * 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 { Metric, ReportHandler } from '../types'; + +export const bindReporter = ( + callback: ReportHandler, + metric: Metric, + po: PerformanceObserver | undefined, + observeAllUpdates?: boolean, +): (() => void) => { + let prevValue: number; + return () => { + if (po && metric.isFinal) { + po.disconnect(); + } + if (metric.value >= 0) { + if (observeAllUpdates || metric.isFinal || document.visibilityState === 'hidden') { + metric.delta = metric.value - (prevValue || 0); + + // Report the metric if there's a non-zero delta, if the metric is + // final, or if no previous value exists (which can happen in the case + // of the document becoming hidden when the metric value is 0). + // See: https://github.com/GoogleChrome/web-vitals/issues/14 + if (metric.delta || metric.isFinal || prevValue === undefined) { + callback(metric); + prevValue = metric.value; + } + } + } + }; +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/generateUniqueID.ts b/packages/tracing/src/browser/web-vitals/lib/generateUniqueID.ts new file mode 100644 index 000000000000..96fc4ad0b27f --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/generateUniqueID.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Google LLC + * + * 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. + */ + +/** + * Performantly generate a unique, 27-char string by combining the current + * timestamp with a 13-digit random number. + * @return {string} + */ +export const generateUniqueID = (): string => { + return `${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/getFirstHidden.ts b/packages/tracing/src/browser/web-vitals/lib/getFirstHidden.ts new file mode 100644 index 000000000000..bd138bcd0eda --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/getFirstHidden.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Google LLC + * + * 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 { onHidden } from './onHidden'; + +let firstHiddenTime: number; + +type HiddenType = { + readonly timeStamp: number; +}; + +export const getFirstHidden = (): HiddenType => { + if (firstHiddenTime === undefined) { + // If the document is hidden when this code runs, assume it was hidden + // since navigation start. This isn't a perfect heuristic, but it's the + // best we can do until an API is available to support querying past + // visibilityState. + firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity; + + // Update the time if/when the document becomes hidden. + onHidden(({ timeStamp }) => (firstHiddenTime = timeStamp), true); + } + + return { + get timeStamp() { + return firstHiddenTime; + }, + }; +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/initMetric.ts b/packages/tracing/src/browser/web-vitals/lib/initMetric.ts new file mode 100644 index 000000000000..c41e484a72b2 --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/initMetric.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Google LLC + * + * 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 { Metric } from '../types'; +import { generateUniqueID } from './generateUniqueID'; + +export const initMetric = (name: Metric['name'], value = -1): Metric => { + return { + name, + value, + delta: 0, + entries: [], + id: generateUniqueID(), + isFinal: false, + }; +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/observe.ts b/packages/tracing/src/browser/web-vitals/lib/observe.ts new file mode 100644 index 000000000000..219d53b7af66 --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/observe.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Google LLC + * + * 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 interface PerformanceEntryHandler { + (entry: PerformanceEntry): void; +} + +/** + * Takes a performance entry type and a callback function, and creates a + * `PerformanceObserver` instance that will observe the specified entry type + * with buffering enabled and call the callback _for each entry_. + * + * This function also feature-detects entry support and wraps the logic in a + * try/catch to avoid errors in unsupporting browsers. + */ +export const observe = (type: string, callback: PerformanceEntryHandler): PerformanceObserver | undefined => { + try { + if (PerformanceObserver.supportedEntryTypes.includes(type)) { + const po: PerformanceObserver = new PerformanceObserver(l => l.getEntries().map(callback)); + + po.observe({ type, buffered: true }); + return po; + } + } catch (e) { + // Do nothing. + } + return; +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/onHidden.ts b/packages/tracing/src/browser/web-vitals/lib/onHidden.ts new file mode 100644 index 000000000000..6d982079e93e --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/onHidden.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Google LLC + * + * 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 interface OnHiddenCallback { + // TODO(philipwalton): add `isPersisted` if needed for bfcache. + ({ timeStamp, isUnloading }: { timeStamp: number; isUnloading: boolean }): void; +} + +let isUnloading = false; +let listenersAdded = false; + +const onPageHide = (event: PageTransitionEvent): void => { + isUnloading = !event.persisted; +}; + +const addListeners = (): void => { + addEventListener('pagehide', onPageHide); + + // `beforeunload` is needed to fix this bug: + // https://bugs.chromium.org/p/chromium/issues/detail?id=987409 + // eslint-disable-next-line @typescript-eslint/no-empty-function + addEventListener('beforeunload', () => {}); +}; + +export const onHidden = (cb: OnHiddenCallback, once = false): void => { + if (!listenersAdded) { + addListeners(); + listenersAdded = true; + } + + addEventListener( + 'visibilitychange', + ({ timeStamp }) => { + if (document.visibilityState === 'hidden') { + cb({ timeStamp, isUnloading }); + } + }, + { capture: true, once }, + ); +}; diff --git a/packages/tracing/src/browser/web-vitals/lib/whenInput.ts b/packages/tracing/src/browser/web-vitals/lib/whenInput.ts new file mode 100644 index 000000000000..6a057fa90e7c --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/lib/whenInput.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Google LLC + * + * 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. + */ + +let inputPromise: Promise; + +export const whenInput = (): Promise => { + if (!inputPromise) { + inputPromise = new Promise(r => { + return ['scroll', 'keydown', 'pointerdown'].map(type => { + addEventListener(type, r, { + once: true, + passive: true, + capture: true, + }); + }); + }); + } + return inputPromise; +}; diff --git a/packages/tracing/src/browser/web-vitals/types.ts b/packages/tracing/src/browser/web-vitals/types.ts new file mode 100644 index 000000000000..d0973c54e4a3 --- /dev/null +++ b/packages/tracing/src/browser/web-vitals/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Google LLC + * + * 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 interface Metric { + // The name of the metric (in acronym form). + name: 'CLS' | 'FCP' | 'FID' | 'LCP' | 'TTFB'; + + // The current value of the metric. + value: number; + + // The delta between the current value and the last-reported value. + // On the first report, `delta` and `value` will always be the same. + delta: number; + + // A unique ID representing this particular metric that's specific to the + // current page. This ID can be used by an analytics tool to dedupe + // multiple values sent for the same metric, or to group multiple deltas + // together and calculate a total. + id: string; + + // `false` if the value of the metric may change in the future, + // for the current page. + isFinal: boolean; + + // Any performance entries used in the metric value calculation. + // Note, entries will be added to the array as the value changes. + entries: PerformanceEntry[]; +} + +export interface ReportHandler { + (metric: Metric): void; +} diff --git a/packages/tracing/src/transaction.ts b/packages/tracing/src/transaction.ts index 43b76262f7fa..7f51f84462d7 100644 --- a/packages/tracing/src/transaction.ts +++ b/packages/tracing/src/transaction.ts @@ -1,5 +1,5 @@ import { getCurrentHub, Hub } from '@sentry/hub'; -import { Transaction as TransactionInterface, TransactionContext } from '@sentry/types'; +import { Event, Measurements, Transaction as TransactionInterface, TransactionContext } from '@sentry/types'; import { isInstanceOf, logger } from '@sentry/utils'; import { Span as SpanClass, SpanRecorder } from './span'; @@ -7,6 +7,7 @@ import { Span as SpanClass, SpanRecorder } from './span'; /** JSDoc */ export class Transaction extends SpanClass implements TransactionInterface { public name: string; + private _measurements: Measurements = {}; /** * The reference to the current hub. @@ -55,6 +56,14 @@ export class Transaction extends SpanClass implements TransactionInterface { this.spanRecorder.add(this); } + /** + * Set observed measurements for this transaction. + * @hidden + */ + public setMeasurements(measurements: Measurements): void { + this._measurements = { ...measurements }; + } + /** * @inheritDoc */ @@ -89,7 +98,7 @@ export class Transaction extends SpanClass implements TransactionInterface { }).endTimestamp; } - return this._hub.captureEvent({ + const transaction: Event = { contexts: { trace: this.getTraceContext(), }, @@ -99,6 +108,15 @@ export class Transaction extends SpanClass implements TransactionInterface { timestamp: this.endTimestamp, transaction: this.name, type: 'transaction', - }); + }; + + const hasMeasurements = Object.keys(this._measurements).length > 0; + + if (hasMeasurements) { + logger.log('[Measurements] Adding measurements to transaction', JSON.stringify(this._measurements, undefined, 2)); + transaction.measurements = this._measurements; + } + + return this._hub.captureEvent(transaction); } } diff --git a/packages/types/src/event.ts b/packages/types/src/event.ts index 38e7a6ad5a21..0b274fd249d5 100644 --- a/packages/types/src/event.ts +++ b/packages/types/src/event.ts @@ -8,6 +8,7 @@ import { SdkInfo } from './sdkinfo'; import { Severity } from './severity'; import { Span } from './span'; import { Stacktrace } from './stacktrace'; +import { Measurements } from './transaction'; import { User } from './user'; /** JSDoc */ @@ -39,6 +40,7 @@ export interface Event { user?: User; type?: EventType; spans?: Span[]; + measurements?: Measurements; } /** JSDoc */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 98a429e9e455..892f44562544 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -26,6 +26,7 @@ export { Stacktrace } from './stacktrace'; export { Status } from './status'; export { CustomSamplingContext, + Measurements, SamplingContext, TraceparentData, Transaction, diff --git a/packages/types/src/transaction.ts b/packages/types/src/transaction.ts index 034922b392dc..2a6bde18e5a4 100644 --- a/packages/types/src/transaction.ts +++ b/packages/types/src/transaction.ts @@ -98,3 +98,5 @@ export interface SamplingContext extends CustomSamplingContext { */ request?: ExtractedNodeRequestData; } + +export type Measurements = Record;