From 817d808ec56c2d8ec3e032e8f1ce6e828399d27c Mon Sep 17 00:00:00 2001 From: lynn <41491154+lynnshaoyu@users.noreply.github.com> Date: Tue, 12 Jul 2022 16:43:37 -0400 Subject: [PATCH] feat: implement trace framework for analytics (#4060) * init commit * add amplitude ts sdk to package.json * add more comments and documentation * respond to vm comments * respond to cmcewen comments * fix: remove unused constants * init commit * adapt to web * add optional event properties to trace * correct telemetry to analytics * change telemetry to analytics in doc * fix: respond to cmcewen comments + initialize analytics in app.tsx + add missing return statement * respond to zzmp comments * fixes * eliminate unnecessary state * respond to part of zzmp comments * respond to zzmp comments round 2 * fixes * respond to zzmp comments --- src/components/AmplitudeAnalytics/Trace.tsx | 55 +++++++++++++++ .../AmplitudeAnalytics/TraceEvent.tsx | 70 +++++++++++++++++++ .../AmplitudeAnalytics/constants.ts | 47 ++++++++++++- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 src/components/AmplitudeAnalytics/Trace.tsx create mode 100644 src/components/AmplitudeAnalytics/TraceEvent.tsx diff --git a/src/components/AmplitudeAnalytics/Trace.tsx b/src/components/AmplitudeAnalytics/Trace.tsx new file mode 100644 index 0000000000..38ef9df183 --- /dev/null +++ b/src/components/AmplitudeAnalytics/Trace.tsx @@ -0,0 +1,55 @@ +import { createContext, memo, PropsWithChildren, useContext, useEffect, useMemo } from 'react' + +import { sendAnalyticsEvent } from '.' +import { ElementName, EventName, ModalName, PageName, SectionName } from './constants' + +export interface ITraceContext { + // Highest order context: eg Swap or Explore. + page?: PageName + + // Enclosed section name. Can be as wide or narrow as necessary to + // provide context. + section?: SectionName | ModalName + + // Element name mostly used to identify events sources + // Does not need to be unique given the higher order page and section. + element?: ElementName +} + +export const TraceContext = createContext({}) + +type TraceProps = { + shouldLogImpression?: boolean // whether to log impression on mount + name?: EventName + properties?: Record +} & ITraceContext + +/** + * Sends an analytics event on mount (if shouldLogImpression is set), + * and propagates the context to child traces. + */ +export const Trace = memo( + ({ shouldLogImpression, name, children, page, section, element, properties }: PropsWithChildren) => { + const parentTrace = useContext(TraceContext) + + const combinedProps = useMemo( + () => ({ + ...parentTrace, + ...Object.fromEntries(Object.entries({ page, section, element }).filter(([_, v]) => v !== undefined)), + }), + [element, parentTrace, page, section] + ) + + useEffect(() => { + if (shouldLogImpression) { + sendAnalyticsEvent(name ?? EventName.PAGE_VIEWED, { ...combinedProps, ...properties }) + } + // Impressions should only be logged on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return {children} + } +) + +Trace.displayName = 'Trace' diff --git a/src/components/AmplitudeAnalytics/TraceEvent.tsx b/src/components/AmplitudeAnalytics/TraceEvent.tsx new file mode 100644 index 0000000000..facc565dc9 --- /dev/null +++ b/src/components/AmplitudeAnalytics/TraceEvent.tsx @@ -0,0 +1,70 @@ +import { Children, cloneElement, isValidElement, memo, PropsWithChildren, SyntheticEvent } from 'react' + +import { sendAnalyticsEvent } from '.' +import { Event, EventName } from './constants' +import { ITraceContext, Trace, TraceContext } from './Trace' + +type TraceEventProps = { + events: Event[] + name: EventName + properties?: Record +} & ITraceContext + +/** + * Analytics instrumentation component that wraps event callbacks with logging logic. + * + * @example + * + * + * + */ +export const TraceEvent = memo((props: PropsWithChildren) => { + const { name, properties, events, children, ...traceProps } = props + + return ( + + + {(traceContext) => + Children.map(children, (child) => { + if (!isValidElement(child)) { + return child + } + + // For each child, augment event handlers defined in `actionNames` with event tracing + return cloneElement(child, getEventHandlers(child, traceContext, events, name, properties)) + }) + } + + + ) +}) + +TraceEvent.displayName = 'TraceEvent' + +/** + * Given a set of child element and action props, returns a spreadable + * object of the event handlers augmented with analytics logging. + */ +function getEventHandlers( + child: React.ReactElement, + traceContext: ITraceContext, + events: Event[], + name: EventName, + properties?: Record +) { + const eventHandlers: Partial) => void>> = {} + + for (const event of events) { + eventHandlers[event] = (eventHandlerArgs: unknown) => { + // call child event handler with original arguments, must be in array + const args = Array.isArray(eventHandlerArgs) ? eventHandlerArgs : [eventHandlerArgs] + child.props[event]?.apply(child, args) + + // augment handler with analytics logging + sendAnalyticsEvent(name, { ...traceContext, ...properties }) + } + } + + // return a spreadable event handler object + return eventHandlers +} diff --git a/src/components/AmplitudeAnalytics/constants.ts b/src/components/AmplitudeAnalytics/constants.ts index 45d9cf2868..1410fe7f3c 100644 --- a/src/components/AmplitudeAnalytics/constants.ts +++ b/src/components/AmplitudeAnalytics/constants.ts @@ -5,6 +5,51 @@ * and logged. */ export enum EventName { - SWAP_SUBMITTED = 'Swap Submitted', PAGE_VIEWED = 'Page Viewed', + SWAP_SUBMITTED = 'Swap Submitted', + // alphabetize additional event names. +} + +/** + * Known pages in the app. Highest order context. + */ +export const enum PageName { + SWAP_PAGE = 'swap-page', + // alphabetize additional page names. +} + +/** + * Sections. Disambiguates low-level elements that may share a name. + * eg a `back` button in a modal will have the same `element`, + * but a different `section`. + */ +export const enum SectionName { + CURRENCY_INPUT_PANEL = 'swap-currency-input', + // alphabetize additional section names. +} + +/** Known modals for analytics purposes. */ +export const enum ModalName { + SWAP = 'swap-modal', + // alphabetize additional modal names. +} + +/** + * Known element names for analytics purposes. + * Use to identify low-level components given a TraceContext + */ +export const enum ElementName { + CONFIRM_SWAP_BUTTON = 'confirm-swap-or-send', + SWAP_BUTTON = 'swap-button', + // alphabetize additional element names. +} + +/** + * Known events that trigger callbacks. + * @example + * + */ +export enum Event { + onClick = 'onClick', + // alphabetize additional events. }