Summary: When reporting errors we could add info about interactions which caused errors. Ability to connect errors and interactions could be quite helpful for analysing and debugging errors and where they are coming from. Reviewed By: passy, mweststrate Differential Revision: D28467575 fbshipit-source-id: bef69917a4d6c786d762a2f6eb75a47fd4e46b0f
266 lines
7.2 KiB
TypeScript
266 lines
7.2 KiB
TypeScript
/**
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import React, {useMemo} from 'react';
|
|
import {Children, cloneElement, createContext, useContext} from 'react';
|
|
import reactElementToJSXString from 'react-element-to-jsx-string';
|
|
import {v4 as uuid} from 'uuid';
|
|
|
|
export type InteractionReport = {
|
|
// Duration of the event handler itself, not including any time the promise handler might have been pending
|
|
duration: number;
|
|
// Duration of the total promise chain return by the event handler.
|
|
totalDuration: number;
|
|
success: 1 | 0;
|
|
error?: string;
|
|
scope: string;
|
|
action: string;
|
|
componentType: string;
|
|
event: string;
|
|
uuid: string;
|
|
};
|
|
|
|
export type InteractionReporter = (report: InteractionReport) => void;
|
|
|
|
let globalInteractionReporter: InteractionReporter = () => {};
|
|
|
|
export function setGlobalInteractionReporter(reporter: InteractionReporter) {
|
|
globalInteractionReporter = reporter;
|
|
}
|
|
|
|
// For unit tests only
|
|
export function resetGlobalInteractionReporter() {
|
|
globalInteractionReporter = () => {};
|
|
}
|
|
|
|
const DEFAULT_SCOPE = 'Flipper';
|
|
|
|
export const TrackingScopeContext = createContext(DEFAULT_SCOPE);
|
|
|
|
export function TrackingScope({
|
|
scope,
|
|
children,
|
|
}: {
|
|
scope: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const baseScope = useContext(TrackingScopeContext);
|
|
return (
|
|
<TrackingScopeContext.Provider
|
|
value={baseScope === DEFAULT_SCOPE ? scope : `${baseScope}:${scope}`}>
|
|
{children}
|
|
</TrackingScopeContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gives the name of the current scope that is currently rendering.
|
|
* Typically the current plugin id, but can be further refined by using TrackingScopes
|
|
*/
|
|
export function useCurrentScopeName(): string {
|
|
return useContext(TrackingScopeContext);
|
|
}
|
|
|
|
export function Tracked({
|
|
events = 'onClick',
|
|
children,
|
|
action,
|
|
}: {
|
|
/**
|
|
* Name of the event handler properties of the child component that should be wrapped
|
|
*/
|
|
events?: string | string[];
|
|
|
|
/*
|
|
* Name of the interaction describing what the user interacted with. If omitted, will take a description of the child
|
|
*/
|
|
action?: string;
|
|
children: React.ReactNode;
|
|
}): React.ReactElement {
|
|
const scope = useCurrentScopeName();
|
|
return Children.map(children, (child: any) => {
|
|
if (!child || typeof child !== 'object') {
|
|
return child;
|
|
}
|
|
if (child.type === Tracked) return child; // avoid double trapping
|
|
const newProps: any = {};
|
|
(typeof events === 'string' ? [events] : events).forEach((event) => {
|
|
const base = child.props[event];
|
|
if (!base) {
|
|
return;
|
|
}
|
|
newProps[event] = wrapInteractionHandler(
|
|
base,
|
|
child,
|
|
event,
|
|
scope,
|
|
action,
|
|
);
|
|
});
|
|
return cloneElement(child, newProps);
|
|
}) as any;
|
|
}
|
|
|
|
export function useTrackedCallback<T extends Function>(
|
|
action: string,
|
|
fn: T,
|
|
deps: any[],
|
|
): T {
|
|
const scope = useContext(TrackingScopeContext);
|
|
return useMemo(() => {
|
|
return wrapInteractionHandler(fn, null, '', scope, action);
|
|
// eslint-disable-next-line
|
|
}, deps) as any;
|
|
}
|
|
|
|
export function wrapInteractionHandler<T extends Function>(
|
|
fn: T,
|
|
element: React.ReactElement | null | string,
|
|
event: string,
|
|
scope: string,
|
|
action?: string,
|
|
): T {
|
|
const interaction_uuid = uuid();
|
|
function report(start: number, initialEnd: number, error?: any) {
|
|
const interactionReport: InteractionReport = {
|
|
duration: initialEnd - start,
|
|
totalDuration: Date.now() - start,
|
|
success: error ? 0 : 1,
|
|
error: error ? '' + error : undefined,
|
|
componentType:
|
|
element === null
|
|
? 'unknown'
|
|
: typeof element === 'string'
|
|
? element
|
|
: describeElementType(element),
|
|
action:
|
|
action ??
|
|
(element && typeof element != 'string'
|
|
? describeElement(element)
|
|
: 'unknown'),
|
|
scope,
|
|
event,
|
|
uuid: interaction_uuid,
|
|
};
|
|
if (error && typeof error === 'object') {
|
|
// associate the error with the interaction caused it
|
|
error.interaction = interactionReport;
|
|
}
|
|
globalInteractionReporter(interactionReport);
|
|
}
|
|
|
|
const res = function trappedInteractionHandler(this: any) {
|
|
let res: any;
|
|
const start = Date.now();
|
|
const r = report.bind(null, start);
|
|
try {
|
|
// eslint-disable-next-line
|
|
res = fn.apply(this, arguments);
|
|
if (Date.now() - start > 20) {
|
|
console.warn('Slow interaction');
|
|
}
|
|
} catch (e) {
|
|
r(Date.now(), e);
|
|
throw e;
|
|
}
|
|
const initialEnd = Date.now();
|
|
if (typeof res?.then === 'function' && typeof res?.catch === 'function') {
|
|
// async / promise
|
|
res.then(
|
|
() => r(initialEnd),
|
|
(error: any) => r(initialEnd, error),
|
|
);
|
|
res = res.catch((error: any) => {
|
|
// we need to create another rejected promise so error is again marked as "unhandled".
|
|
return Promise.reject(error);
|
|
});
|
|
} else {
|
|
// not a Promise
|
|
r(initialEnd);
|
|
}
|
|
return res;
|
|
} as any;
|
|
res.flipperTracked = true; // Avoid double wrapping / handling, if e.g. Button is wrapped in Tracked
|
|
return res;
|
|
}
|
|
|
|
export function describeElement(element: React.ReactElement): string {
|
|
const describing = element.props.title ?? element.props.children;
|
|
if (typeof describing === 'string') {
|
|
return describing;
|
|
}
|
|
if (typeof element.key === 'string') {
|
|
return element.key;
|
|
}
|
|
return stringifyElement(element);
|
|
}
|
|
|
|
function describeElementType(element: React.ReactElement): string {
|
|
const t = element.type as any;
|
|
// cases: native dom element, named (class) component, named function component, classname
|
|
return typeof t === 'string'
|
|
? t
|
|
: t?.displayName ?? t?.name ?? t?.constructor?.name ?? 'unknown';
|
|
}
|
|
|
|
export function withTrackingScope<T>(component: T): T;
|
|
export function withTrackingScope(Component: any) {
|
|
return function WithTrackingScope(props: any) {
|
|
const scope =
|
|
Component.displayName ?? Component.name ?? Component.constructor?.name;
|
|
if (!scope || typeof scope !== 'string') {
|
|
throw new Error('Failed to find component name for trackingScope');
|
|
}
|
|
return (
|
|
<TrackingScope scope={scope}>
|
|
<Component {...props} />
|
|
</TrackingScope>
|
|
);
|
|
};
|
|
}
|
|
|
|
// @ts-ignore
|
|
global.FlipperTrackingScopeContext = TrackingScopeContext;
|
|
//@ts-ignore
|
|
global.FlipperTracked = Tracked;
|
|
|
|
// @ts-ignore
|
|
global.flipperTrackInteraction = function flipperTrackInteraction(
|
|
elementType: string,
|
|
event: string,
|
|
scope: string,
|
|
action: string | React.ReactElement | null,
|
|
fn: Function,
|
|
...args: any[]
|
|
): void {
|
|
// @ts-ignore
|
|
if (fn.flipperTracked) {
|
|
return fn(...args);
|
|
}
|
|
return wrapInteractionHandler(
|
|
fn,
|
|
elementType,
|
|
event,
|
|
scope,
|
|
!action
|
|
? 'unknown action'
|
|
: typeof action === 'string'
|
|
? action
|
|
: stringifyElement(action),
|
|
)(...args);
|
|
};
|
|
|
|
function stringifyElement(element: any): string {
|
|
if (!element) return 'unknown element';
|
|
if (typeof element === 'string') return element;
|
|
if (Array.isArray(element))
|
|
return element.filter(Boolean).map(stringifyElement).join('');
|
|
return reactElementToJSXString(element).substr(0, 200).replace(/\n/g, ' ');
|
|
}
|