From cf194aebfed8c349c6432a5996233a8c1e5cee8f Mon Sep 17 00:00:00 2001 From: Ruslan Shestopalyuk Date: Thu, 16 Feb 2023 06:21:43 -0800 Subject: [PATCH] Implement durationThreshold option for PerformanceObserver (#36152) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/36152 [Changelog][Internal] By [the W3C standard](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe), `PerformanceObserver.observer` can optionally take a `durationThreshold` option, so that only entries with duration larger than the threshold are reported. This diff adds support for this on the RN side, as well as unit tests for this feature on the JS side. NOTE: The standard suggests that default value for this is 104s. I left it at 0 for now, as for the RN use cases t may be to too high (needs discussion). Reviewed By: rubennorte Differential Revision: D43154319 fbshipit-source-id: 0f9d435506f48d8e8521e408211347e8391d22fc --- .../NativePerformanceObserver.cpp | 8 + .../NativePerformanceObserver.h | 5 + .../NativePerformanceObserver.js | 4 + .../PerformanceEntryReporter.cpp | 16 +- .../WebPerformance/PerformanceEntryReporter.h | 7 + .../WebPerformance/PerformanceObserver.js | 83 +++++++-- .../__mocks__/NativePerformanceObserver.js | 16 ++ .../__tests__/PerformanceObserver-test.js | 159 ++++++++++++++++++ 8 files changed, 281 insertions(+), 17 deletions(-) diff --git a/Libraries/WebPerformance/NativePerformanceObserver.cpp b/Libraries/WebPerformance/NativePerformanceObserver.cpp index e0701acfe6a..200140fdae1 100644 --- a/Libraries/WebPerformance/NativePerformanceObserver.cpp +++ b/Libraries/WebPerformance/NativePerformanceObserver.cpp @@ -59,4 +59,12 @@ NativePerformanceObserver::getEventCounts(jsi::Runtime &rt) { eventCounts.begin(), eventCounts.end()); } +void NativePerformanceObserver::setDurationThreshold( + jsi::Runtime &rt, + int32_t entryType, + double durationThreshold) { + PerformanceEntryReporter::getInstance().setDurationThreshold( + static_cast(entryType), durationThreshold); +} + } // namespace facebook::react diff --git a/Libraries/WebPerformance/NativePerformanceObserver.h b/Libraries/WebPerformance/NativePerformanceObserver.h index 5254628d460..577bdee0f73 100644 --- a/Libraries/WebPerformance/NativePerformanceObserver.h +++ b/Libraries/WebPerformance/NativePerformanceObserver.h @@ -74,6 +74,11 @@ class NativePerformanceObserver std::vector> getEventCounts( jsi::Runtime &rt); + void setDurationThreshold( + jsi::Runtime &rt, + int32_t entryType, + double durationThreshold); + private: }; diff --git a/Libraries/WebPerformance/NativePerformanceObserver.js b/Libraries/WebPerformance/NativePerformanceObserver.js index 964c4e99933..86634b60213 100644 --- a/Libraries/WebPerformance/NativePerformanceObserver.js +++ b/Libraries/WebPerformance/NativePerformanceObserver.js @@ -37,6 +37,10 @@ export interface Spec extends TurboModule { +setOnPerformanceEntryCallback: (callback?: () => void) => void; +logRawEntry: (entry: RawPerformanceEntry) => void; +getEventCounts: () => $ReadOnlyArray<[string, number]>; + +setDurationThreshold: ( + entryType: RawPerformanceEntryType, + durationThreshold: number, + ) => void; } export default (TurboModuleRegistry.get( diff --git a/Libraries/WebPerformance/PerformanceEntryReporter.cpp b/Libraries/WebPerformance/PerformanceEntryReporter.cpp index 7c524ae0ec1..a741857de94 100644 --- a/Libraries/WebPerformance/PerformanceEntryReporter.cpp +++ b/Libraries/WebPerformance/PerformanceEntryReporter.cpp @@ -31,8 +31,17 @@ void PerformanceEntryReporter::setReportingCallback( } void PerformanceEntryReporter::startReporting(PerformanceEntryType entryType) { - reportingType_[static_cast(entryType)] = true; + int entryTypeIdx = static_cast(entryType); + reportingType_[entryTypeIdx] = true; + durationThreshold_[entryTypeIdx] = DEFAULT_DURATION_THRESHOLD; } + +void PerformanceEntryReporter::setDurationThreshold( + PerformanceEntryType entryType, + double durationThreshold) { + durationThreshold_[static_cast(entryType)] = durationThreshold; +} + void PerformanceEntryReporter::stopReporting(PerformanceEntryType entryType) { reportingType_[static_cast(entryType)] = false; } @@ -56,6 +65,11 @@ void PerformanceEntryReporter::logEntry(const RawPerformanceEntry &entry) { return; } + if (entry.duration < durationThreshold_[entry.entryType]) { + // The entries duration is lower than the desired reporting threshold, skip + return; + } + std::lock_guard lock(entriesMutex_); if (entries_.size() == MAX_ENTRY_BUFFER_SIZE) { diff --git a/Libraries/WebPerformance/PerformanceEntryReporter.h b/Libraries/WebPerformance/PerformanceEntryReporter.h index 655f58ceab3..721a9ac6984 100644 --- a/Libraries/WebPerformance/PerformanceEntryReporter.h +++ b/Libraries/WebPerformance/PerformanceEntryReporter.h @@ -44,6 +44,8 @@ using PerformanceMarkRegistryType = std:: // memory for the sake of the "Performance.measure" mark name lookup constexpr size_t MARKS_BUFFER_SIZE = 1024; +constexpr double DEFAULT_DURATION_THRESHOLD = 0.0; + enum class PerformanceEntryType { UNDEFINED = 0, MARK = 1, @@ -66,6 +68,9 @@ class PerformanceEntryReporter : public EventLogger { void setReportingCallback(std::optional> callback); void startReporting(PerformanceEntryType entryType); void stopReporting(PerformanceEntryType entryType); + void setDurationThreshold( + PerformanceEntryType entryType, + double durationThreshold); GetPendingEntriesResult popPendingEntries(); void logEntry(const RawPerformanceEntry &entry); @@ -122,6 +127,8 @@ class PerformanceEntryReporter : public EventLogger { std::mutex entriesMutex_; std::array reportingType_{false}; std::unordered_map eventCounts_; + std::array durationThreshold_{ + DEFAULT_DURATION_THRESHOLD}; // Mark registry for "measure" lookup PerformanceMarkRegistryType marksRegistry_; diff --git a/Libraries/WebPerformance/PerformanceObserver.js b/Libraries/WebPerformance/PerformanceObserver.js index ca7d081a0f8..776c9f0ced1 100644 --- a/Libraries/WebPerformance/PerformanceObserver.js +++ b/Libraries/WebPerformance/PerformanceObserver.js @@ -8,7 +8,7 @@ * @flow strict */ -import type {PerformanceEntryType} from './PerformanceEntry'; +import type {HighResTimeStamp, PerformanceEntryType} from './PerformanceEntry'; import warnOnce from '../Utilities/warnOnce'; import NativePerformanceObserver from './NativePerformanceObserver'; @@ -62,11 +62,13 @@ export type PerformanceObserverInit = } | { type: PerformanceEntryType, + durationThreshold?: HighResTimeStamp, }; type PerformanceObserverConfig = {| callback: PerformanceObserverCallback, - entryTypes: $ReadOnlySet, + // Map of {entryType: durationThreshold} + entryTypes: $ReadOnlyMap, |}; const observerCountPerEntryType: Map = new Map(); @@ -87,9 +89,13 @@ const onPerformanceEntry = () => { } const entries = rawEntries.map(rawToPerformanceEntry); for (const [observer, observerConfig] of registeredObservers.entries()) { - const entriesForObserver: PerformanceEntryList = entries.filter( - entry => observerConfig.entryTypes.has(entry.entryType) !== -1, - ); + const entriesForObserver: PerformanceEntryList = entries.filter(entry => { + if (!observerConfig.entryTypes.has(entry.entryType)) { + return false; + } + const durationThreshold = observerConfig.entryTypes.get(entry.entryType); + return entry.duration >= (durationThreshold ?? 0); + }); observerConfig.callback( new PerformanceObserverEntryList(entriesForObserver), observer, @@ -105,6 +111,24 @@ export function warnNoNativePerformanceObserver() { ); } +function applyDurationThresholds() { + const durationThresholds: Map = Array.from( + registeredObservers.values(), + ) + .map(config => config.entryTypes) + .reduce( + (accumulator, currentValue) => union(accumulator, currentValue), + new Map(), + ); + + for (const [entryType, durationThreshold] of durationThresholds) { + NativePerformanceObserver?.setDurationThreshold( + performanceEntryTypeToRaw(entryType), + durationThreshold ?? 0, + ); + } +} + /** * Implementation of the PerformanceObserver interface for RN, * corresponding to the standard in https://www.w3.org/TR/performance-timeline/ @@ -145,10 +169,14 @@ export default class PerformanceObserver { if (options.entryTypes) { this._type = 'multiple'; - requestedEntryTypes = new Set(options.entryTypes); + requestedEntryTypes = new Map( + options.entryTypes.map(t => [t, undefined]), + ); } else { this._type = 'single'; - requestedEntryTypes = new Set([options.type]); + requestedEntryTypes = new Map([ + [options.type, options.durationThreshold], + ]); } // The same observer may receive multiple calls to "observe", so we need @@ -178,19 +206,22 @@ export default class PerformanceObserver { // We only need to start listenening to new entry types being observed in // this observer. const newEntryTypes = currentEntryTypes - ? difference(requestedEntryTypes, currentEntryTypes) - : requestedEntryTypes; + ? difference( + new Set(requestedEntryTypes.keys()), + new Set(currentEntryTypes.keys()), + ) + : new Set(requestedEntryTypes.keys()); for (const type of newEntryTypes) { if (!observerCountPerEntryType.has(type)) { - NativePerformanceObserver.startReporting( - performanceEntryTypeToRaw(type), - ); + const rawType = performanceEntryTypeToRaw(type); + NativePerformanceObserver.startReporting(rawType); } observerCountPerEntryType.set( type, (observerCountPerEntryType.get(type) ?? 0) + 1, ); } + applyDurationThresholds(); } disconnect(): void { @@ -205,7 +236,7 @@ export default class PerformanceObserver { } // Disconnect this observer - for (const type of observerConfig.entryTypes) { + for (const type of observerConfig.entryTypes.keys()) { const numberOfObserversForThisType = observerCountPerEntryType.get(type) ?? 0; if (numberOfObserversForThisType === 1) { @@ -224,10 +255,12 @@ export default class PerformanceObserver { NativePerformanceObserver.setOnPerformanceEntryCallback(undefined); isOnPerformanceEntryCallbackSet = false; } + + applyDurationThresholds(); } _validateObserveOptions(options: PerformanceObserverInit): void { - const {type, entryTypes} = options; + const {type, entryTypes, durationThreshold} = options; if (!type && !entryTypes) { throw new TypeError( @@ -252,14 +285,32 @@ export default class PerformanceObserver { "Failed to execute 'observe' on 'PerformanceObserver': This PerformanceObserver has performed observe({type:...}, therefore it cannot perform observe({entryTypes:...})", ); } + + if (entryTypes && durationThreshold !== undefined) { + throw new TypeError( + "Failed to execute 'observe' on 'PerformanceObserver': An observe() call must not include both entryTypes and durationThreshold arguments.", + ); + } } static supportedEntryTypes: $ReadOnlyArray = Object.freeze(['mark', 'measure', 'event']); } -function union(a: $ReadOnlySet, b: $ReadOnlySet): Set { - return new Set([...a, ...b]); +// As a Set union, except if value exists in both, we take minimum +function union( + a: $ReadOnlyMap, + b: $ReadOnlyMap, +): Map { + const res = new Map(); + for (const [k, v] of a) { + if (!b.has(k)) { + res.set(k, v); + } else { + res.set(k, Math.min(v ?? 0, b.get(k) ?? 0)); + } + } + return res; } function difference(a: $ReadOnlySet, b: $ReadOnlySet): Set { diff --git a/Libraries/WebPerformance/__mocks__/NativePerformanceObserver.js b/Libraries/WebPerformance/__mocks__/NativePerformanceObserver.js index 4379e30ab1d..98c3313bf04 100644 --- a/Libraries/WebPerformance/__mocks__/NativePerformanceObserver.js +++ b/Libraries/WebPerformance/__mocks__/NativePerformanceObserver.js @@ -19,6 +19,7 @@ import {RawPerformanceEntryTypeValues} from '../RawPerformanceEntry'; const reportingType: Set = new Set(); const eventCounts: Map = new Map(); +const durationThresholds: Map = new Map(); let entries: Array = []; let onPerformanceEntryCallback: ?() => void; @@ -29,6 +30,7 @@ const NativePerformanceObserverMock: NativePerformanceObserver = { stopReporting: (entryType: RawPerformanceEntryType) => { reportingType.delete(entryType); + durationThresholds.delete(entryType); }, popPendingEntries: (): GetPendingEntriesResult => { @@ -46,6 +48,13 @@ const NativePerformanceObserverMock: NativePerformanceObserver = { logRawEntry: (entry: RawPerformanceEntry) => { if (reportingType.has(entry.entryType)) { + const durationThreshold = durationThresholds.get(entry.entryType); + if ( + durationThreshold !== undefined && + entry.duration < durationThreshold + ) { + return; + } entries.push(entry); // $FlowFixMe[incompatible-call] global.queueMicrotask(() => { @@ -61,6 +70,13 @@ const NativePerformanceObserverMock: NativePerformanceObserver = { getEventCounts: (): $ReadOnlyArray<[string, number]> => { return Array.from(eventCounts.entries()); }, + + setDurationThreshold: ( + entryType: RawPerformanceEntryType, + durationThreshold: number, + ) => { + durationThresholds.set(entryType, durationThreshold); + }, }; export default NativePerformanceObserverMock; diff --git a/Libraries/WebPerformance/__tests__/PerformanceObserver-test.js b/Libraries/WebPerformance/__tests__/PerformanceObserver-test.js index 060b9a9ab9f..a6e6a37be9f 100644 --- a/Libraries/WebPerformance/__tests__/PerformanceObserver-test.js +++ b/Libraries/WebPerformance/__tests__/PerformanceObserver-test.js @@ -26,6 +26,7 @@ describe('PerformanceObserver', () => { let totalEntries = 0; const observer = new PerformanceObserver((list, _observer) => { + expect(_observer).toBe(observer); const entries = list.getEntries(); expect(entries).toHaveLength(1); const entry = entries[0]; @@ -46,4 +47,162 @@ describe('PerformanceObserver', () => { expect(totalEntries).toBe(1); observer.disconnect(); }); + + it('prevents durationThreshold to be used together with entryTypes', async () => { + const observer = new PerformanceObserver((list, _observer) => {}); + + expect(() => + observer.observe({entryTypes: ['mark'], durationThreshold: 100}), + ).toThrow(); + }); + + it('handles durationThreshold argument as expected', async () => { + let entries = []; + const observer = new PerformanceObserver((list, _observer) => { + entries = [...entries, ...list.getEntries()]; + }); + + observer.observe({type: 'mark', durationThreshold: 100}); + + NativePerformanceObserver.logRawEntry({ + name: 'mark1', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 200, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark2', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 20, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark3', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 100, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark4', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 500, + }); + + await jest.runAllTicks(); + expect(entries).toHaveLength(3); + expect(entries.map(e => e.name)).toStrictEqual(['mark1', 'mark3', 'mark4']); + }); + + it('correctly works with multiple PerformanceObservers with durationThreshold', async () => { + let entries1 = []; + const observer1 = new PerformanceObserver((list, _observer) => { + entries1 = [...entries1, ...list.getEntries()]; + }); + + let entries2 = []; + const observer2 = new PerformanceObserver((list, _observer) => { + entries2 = [...entries2, ...list.getEntries()]; + }); + + let entries3 = []; + const observer3 = new PerformanceObserver((list, _observer) => { + entries3 = [...entries3, ...list.getEntries()]; + }); + + let entries4 = []; + const observer4 = new PerformanceObserver((list, _observer) => { + entries4 = [...entries4, ...list.getEntries()]; + }); + + observer2.observe({type: 'mark', durationThreshold: 200}); + observer1.observe({type: 'mark', durationThreshold: 100}); + observer3.observe({type: 'mark', durationThreshold: 300}); + observer3.observe({type: 'measure', durationThreshold: 500}); + observer4.observe({entryTypes: ['mark', 'measure']}); + + NativePerformanceObserver.logRawEntry({ + name: 'mark1', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 200, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark2', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 20, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark3', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 100, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark4', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 500, + }); + + await jest.runAllTicks(); + observer1.disconnect(); + + NativePerformanceObserver.logRawEntry({ + name: 'mark5', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 200, + }); + + NativePerformanceObserver.logRawEntry({ + name: 'mark6', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 300, + }); + + await jest.runAllTicks(); + observer3.disconnect(); + + NativePerformanceObserver.logRawEntry({ + name: 'mark7', + entryType: RawPerformanceEntryTypeValues.MARK, + startTime: 0, + duration: 200, + }); + + await jest.runAllTicks(); + observer4.disconnect(); + + expect(entries1.map(e => e.name)).toStrictEqual([ + 'mark1', + 'mark3', + 'mark4', + ]); + expect(entries2.map(e => e.name)).toStrictEqual([ + 'mark1', + 'mark4', + 'mark5', + 'mark6', + 'mark7', + ]); + expect(entries3.map(e => e.name)).toStrictEqual(['mark4', 'mark6']); + expect(entries4.map(e => e.name)).toStrictEqual([ + 'mark1', + 'mark2', + 'mark3', + 'mark4', + 'mark5', + 'mark6', + 'mark7', + ]); + }); });