diff --git a/opentrons-ai-client/src/OpentronsAI.tsx b/opentrons-ai-client/src/OpentronsAI.tsx index 4c617ec7cc1..f205d1a5f5a 100644 --- a/opentrons-ai-client/src/OpentronsAI.tsx +++ b/opentrons-ai-client/src/OpentronsAI.tsx @@ -13,14 +13,20 @@ import { useAtom } from 'jotai' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Loading } from './molecules/Loading' -import { tokenAtom } from './resources/atoms' +import { mixpanelAtom, tokenAtom } from './resources/atoms' import { useGetAccessToken } from './resources/hooks' +import { initializeMixpanel } from './analytics/mixpanel' +import { useTrackEvent } from './resources/hooks/useTrackEvent' export function OpentronsAI(): JSX.Element | null { const { t } = useTranslation('protocol_generator') const { isAuthenticated, logout, isLoading, loginWithRedirect } = useAuth0() const [, setToken] = useAtom(tokenAtom) + const [mixpanel] = useAtom(mixpanelAtom) const { getAccessToken } = useGetAccessToken() + const trackEvent = useTrackEvent() + + initializeMixpanel(mixpanel) const fetchAccessToken = async (): Promise => { try { @@ -40,6 +46,12 @@ export function OpentronsAI(): JSX.Element | null { } }, [isAuthenticated, isLoading, loginWithRedirect]) + useEffect(() => { + if (isAuthenticated) { + trackEvent({ name: 'user-login', properties: {} }) + } + }, [isAuthenticated]) + if (isLoading) { return } diff --git a/opentrons-ai-client/src/analytics/mixpanel.ts b/opentrons-ai-client/src/analytics/mixpanel.ts new file mode 100644 index 00000000000..b561bc3acce --- /dev/null +++ b/opentrons-ai-client/src/analytics/mixpanel.ts @@ -0,0 +1,69 @@ +import mixpanel from 'mixpanel-browser' +import { getHasOptedIn } from './selectors' + +export const getIsProduction = (): boolean => + global.location.host === 'designer.opentrons.com' // UPDATE THIS TO CORRECT URL + +export type AnalyticsEvent = + | { + name: string + properties: Record + superProperties?: Record + } + | { superProperties: Record } + +// pulled in from environment at build time +const MIXPANEL_ID = getIsProduction() + ? process.env.OT_AI_CLIENT_MIXPANEL_ID + : 'process.env.OT_AI_CLIENT_MIXPANEL_DEV_ID' + +const MIXPANEL_OPTS = { + // opt out by default + opt_out_tracking_by_default: true, +} + +export function initializeMixpanel(state: any): void { + const optedIn = getHasOptedIn(state) ?? false + if (MIXPANEL_ID != null) { + console.debug('Initializing Mixpanel', { optedIn }) + + mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) + setMixpanelTracking(optedIn) + trackEvent({ name: 'appOpen', properties: {} }, optedIn) // TODO IMMEDIATELY: do we want this? + } else { + console.warn('MIXPANEL_ID not found; this is a bug if build is production') + } +} + +export function trackEvent(event: AnalyticsEvent, optedIn: boolean): void { + console.debug('Trackable event', { event, optedIn }) + if (MIXPANEL_ID != null && optedIn) { + if ('superProperties' in event && event.superProperties != null) { + mixpanel.register(event.superProperties) + } + if ('name' in event && event.name != null) { + mixpanel.track(event.name, event.properties) + } + } +} + +export function setMixpanelTracking(optedIn: boolean): void { + if (MIXPANEL_ID != null) { + if (optedIn) { + console.debug('User has opted into analytics; tracking with Mixpanel') + mixpanel.opt_in_tracking() + // Register "super properties" which are included with all events + mixpanel.register({ + appVersion: 'test', // TODO update this? + // NOTE(IL, 2020): Since PD may be in the same Mixpanel project as other OT web apps, this 'appName' property is intended to distinguish it + appName: 'opentronsAIClient', + }) + } else { + console.debug( + 'User has opted out of analytics; stopping Mixpanel tracking' + ) + mixpanel.opt_out_tracking() + mixpanel.reset() + } + } +} diff --git a/opentrons-ai-client/src/analytics/selectors.ts b/opentrons-ai-client/src/analytics/selectors.ts new file mode 100644 index 00000000000..b55165f3049 --- /dev/null +++ b/opentrons-ai-client/src/analytics/selectors.ts @@ -0,0 +1,2 @@ +export const getHasOptedIn = (state: any): boolean | null => + state.analytics.hasOptedIn diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts index 2065f7e89e2..73d45fb165b 100644 --- a/opentrons-ai-client/src/resources/atoms.ts +++ b/opentrons-ai-client/src/resources/atoms.ts @@ -1,6 +1,6 @@ // jotai's atoms import { atom } from 'jotai' -import type { Chat, ChatData } from './types' +import type { Chat, ChatData, Mixpanel } from './types' /** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */ export const chatDataAtom = atom([]) @@ -8,3 +8,7 @@ export const chatDataAtom = atom([]) export const chatHistoryAtom = atom([]) export const tokenAtom = atom(null) + +export const mixpanelAtom = atom({ + analytics: { hasOptedIn: true }, // TODO: set to false +}) diff --git a/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts new file mode 100644 index 00000000000..d150cb14853 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts @@ -0,0 +1,16 @@ +import { useAtom } from 'jotai' +import { trackEvent } from '../../analytics/mixpanel' +import { mixpanelAtom } from '../atoms' +import type { AnalyticsEvent } from '../types' + +/** + * React hook to send an analytics tracking event directly from a component + * + * @returns {AnalyticsEvent => void} track event function + */ +export function useTrackEvent(): (e: AnalyticsEvent) => void { + const [mixpanel] = useAtom(mixpanelAtom) + return event => { + trackEvent(event, mixpanel?.analytics.hasOptedIn ?? false) + } +} diff --git a/opentrons-ai-client/src/resources/types.ts b/opentrons-ai-client/src/resources/types.ts index a18254ff245..067c1ef9764 100644 --- a/opentrons-ai-client/src/resources/types.ts +++ b/opentrons-ai-client/src/resources/types.ts @@ -30,3 +30,15 @@ export interface RouteProps { path: string navLinkTo: string } + +export interface Mixpanel { + analytics: { + hasOptedIn: boolean + } +} + +export interface AnalyticsEvent { + name: string + properties: Record + superProperties?: Record +}