Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement EventTracker abstraction and Amplitude implementation #3650

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Router } from 'react-router-dom'
import { CompatRouter } from 'react-router-dom-v5-compat'

import ErrorBoundary from 'layouts/shared/ErrorBoundary'
import { initEventTracker } from 'services/events/events'
import { withFeatureFlagProvider } from 'shared/featureFlags'

import App from './App'
Expand All @@ -38,6 +39,7 @@ const history = createBrowserHistory()

const TOO_MANY_REQUESTS_ERROR_CODE = 429

initEventTracker()
setupSentry({ history })

const queryClient = new QueryClient({
Expand Down
2 changes: 2 additions & 0 deletions src/layouts/BaseLayout/BaseLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import NetworkErrorBoundary from 'layouts/shared/NetworkErrorBoundary'
import SilentNetworkErrorWrapper from 'layouts/shared/SilentNetworkErrorWrapper'
import ToastNotifications from 'layouts/ToastNotifications'
import { RepoBreadcrumbProvider } from 'pages/RepoPage/context'
import { useEventContext } from 'services/events/events'
import { useImpersonate } from 'services/impersonate'
import { useTracking } from 'services/tracking'
import GlobalBanners from 'shared/GlobalBanners'
Expand Down Expand Up @@ -77,6 +78,7 @@ interface URLParams {
function BaseLayout({ children }: React.PropsWithChildren) {
const { provider, owner, repo } = useParams<URLParams>()
useTracking()
useEventContext()
const { isImpersonating } = useImpersonate()
const {
isFullExperience,
Expand Down
9 changes: 9 additions & 0 deletions src/layouts/Header/components/UserDropdown/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useHistory, useParams } from 'react-router-dom'

import config from 'config'

import { eventTracker } from 'services/events/events'
import { useUser } from 'services/user'
import { Provider } from 'shared/api/helpers'
import { providerToName } from 'shared/utils/provider'
Expand Down Expand Up @@ -42,6 +43,14 @@ function UserDropdown() {
{
to: { pageName: 'codecovAppInstallation' },
children: 'Install Codecov app',
onClick: () =>
eventTracker().track({
type: 'Button Clicked',
properties: {
buttonType: 'Install GitHub App',
buttonLocation: 'User dropdown',
},
}),
} as DropdownItem,
]
: []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useParams } from 'react-router-dom'

import { eventTracker } from 'services/events/events'
import { providerToName } from 'shared/utils/provider'
import A from 'ui/A'
import Banner from 'ui/Banner'
Expand All @@ -21,6 +22,15 @@ const GithubConfigBanner = () => {
<A
data-testid="codecovGithubApp-link"
to={{ pageName: 'codecovGithubAppSelectTarget' }}
onClick={() =>
eventTracker().track({
type: 'Button Clicked',
properties: {
buttonType: 'Install GitHub App',
buttonLocation: 'Configure GitHub app banner',
},
})
}
>
Codecov&apos;s GitHub app
</A>
Expand Down
61 changes: 61 additions & 0 deletions src/services/events/amplitude/amplitude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as amplitude from '@amplitude/analytics-browser'

import { providerToInternalProvider } from 'shared/utils/provider'

import { Event, EventContext, EventTracker, Identity } from '../types'

const AMPLITUDE_API_KEY = process.env.REACT_APP_AMPLITUDE_API_KEY

export function initAmplitude() {
if (!AMPLITUDE_API_KEY) {
throw new Error(
'AMPLITUDE_API_KEY is not defined. Amplitude events will not be tracked.'
)
}
amplitude.init(AMPLITUDE_API_KEY, {
// Disable all autocapture - may change this in the future
autocapture: false,
minIdLength: 1, // Necessary to accommodate our owner ids
})
}

export class AmplitudeEventTracker implements EventTracker {
context: EventContext = {}
identity?: Identity

identify(identity: Identity) {
if (JSON.stringify(this.identity) === JSON.stringify(identity)) {
// Don't identify this user again this session.
return
}

amplitude.setUserId(identity.userOwnerId.toString())
const identifyEvent = new amplitude.Identify()
identifyEvent.set('provider', providerToInternalProvider(identity.provider))
amplitude.identify(identifyEvent)

this.identity = identity
}

track(event: Event) {
amplitude.track({
// eslint-disable-next-line camelcase
event_type: event.type,
// eslint-disable-next-line camelcase
event_properties: {
...event.properties,
...this.context,
},
// This attaches the event to the owner's user group (org) as well
groups: this.context.owner?.id
? {
org: this.context.owner.id,
}
: undefined,
})
}

setContext(context: EventContext) {
this.context = context
}
}
64 changes: 64 additions & 0 deletions src/services/events/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { captureException } from '@sentry/react'
import { useEffect } from 'react'
import { useParams } from 'react-router'

import { useRepo } from 'services/repo'
import { useOwner } from 'services/user'

import { AmplitudeEventTracker, initAmplitude } from './amplitude/amplitude'
import { StubbedEventTracker } from './stub'
import { EventTracker } from './types'

// EventTracker singleton
let EVENT_TRACKER: EventTracker = new StubbedEventTracker()

export function initEventTracker(): void {
// Sets the global EventTracker singleton and calls necessary init functions
try {
initAmplitude()
EVENT_TRACKER = new AmplitudeEventTracker()
} catch (e) {
if (process.env.REACT_APP_ENV === 'production') {
// If in production, we need to know this has occured.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: occurred

captureException(e)
}
}
}

// Returns the global EventTracker instance.
export function eventTracker(): EventTracker {
return EVENT_TRACKER
}

// Hook to keep the global EventTracker's context up-to-date.
export function useEventContext() {
const { provider, owner, repo } = useParams<{
provider?: string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This can be typed to "Provider"

owner?: string
repo?: string
}>()

const { data: ownerData } = useOwner({ username: owner })
const { data: repoData } = useRepo({
provider: provider || '',
owner: owner || '',
repo: repo || '',
opts: { enabled: !!(provider && owner && repo) },
})

useEffect(() => {
EVENT_TRACKER.setContext({
owner: ownerData?.ownerid
? {
id: ownerData?.ownerid,
}
: undefined,
repo: repoData?.repository
? {
id: repoData?.repository?.repoid,
isPrivate: repoData?.repository.private || undefined,
}
: undefined,
})
}, [ownerData, repoData])
}
7 changes: 7 additions & 0 deletions src/services/events/stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Event, EventContext, EventTracker, Identity } from './types'

export class StubbedEventTracker implements EventTracker {
identify(_identity: Identity): void {}
track(_event: Event): void {}
setContext(_context: EventContext): void {}
}
76 changes: 76 additions & 0 deletions src/services/events/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Provider } from 'shared/api/helpers'

//
// Add new events to the the Event union type below!
//
// Adding event types in this way provides type safety for names and event
// properties.
// E.g., every 'Button Clicked' event must have the buttonType property.
//
// Guidelines:
// - Event names should:
// - be of the form "[Noun] [Past-tense verb]",
// - have each word capitalized,
// - describe an action taken by the user.
// - Keep the values of `type` very generic as we have a limited number of
// them. Instead, add more detail in `properties` where possible.
// - Try to keep event property names unique to the event type to avoid
// accidental correlation of unrelated events.
// - Never include names, only use ids. E.g., use repoid instead of repo name.
//

export type Event =
| {
type: 'Button Clicked'
properties: {
buttonType: 'Install GitHub App' | 'Configure Repo'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should do a "Type of" and have these buttonTypes (and other event properties that will have a range of set values) live somewhere else for easier parsing and clarity

buttonLocation?: string
}
}
| {
type: 'Page Viewed'
properties: {
pageName: 'Owner Page'
}
}

export type Identity = {
userOwnerId: number
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this named as such just cuz owners can be orgs and users?

provider: Provider
}

// Describes the context to be attached to events. We can extend this as needs
// arise in the future.
export type EventContext = {
// owner the event is being performed ON, not BY.
owner?: {
id: number
}
repo?: {
id: number
isPrivate?: boolean
}
}

export abstract class EventTracker {
// Identifies the user this session belongs to.
identify(_identity: Identity): void {
throw new Error(
'EventTracker is abstract. Method identify must be implemented.'
)
}

// Tracks an event
track(_event: Event): void {
throw new Error(
'EventTracker is abstract. Method track must be implemented.'
)
}

// Sets the current EventContext
setContext(_context: EventContext): void {
throw new Error(
'EventTracker is abstract. Method setContext must be implemented.'
)
}
}
3 changes: 3 additions & 0 deletions src/services/repo/useRepo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

const RepositorySchema = z.object({
__typename: z.literal('Repository'),
repoid: z.number(),
private: z.boolean().nullable(),
uploadToken: z.string().nullable(),
defaultBranch: z.string().nullable(),
Expand Down Expand Up @@ -47,6 +48,7 @@ const query = `
repository(name: $repo) {
__typename
... on Repository {
repoid
private
uploadToken
defaultBranch
Expand All @@ -73,6 +75,7 @@ type UseRepoArgs = {
repo: string
opts?: {
refetchOnWindowFocus?: boolean
enabled?: boolean
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/services/user/useOwner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function useOwner({
})
}

return res?.data?.owner
return parsedRes?.data?.owner
}),
...opts,
})
Expand Down
20 changes: 17 additions & 3 deletions src/services/user/useUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { z } from 'zod'

import { eventTracker } from 'services/events/events'
import Api from 'shared/api'
import { NetworkErrorObject } from 'shared/api/helpers'
import { NetworkErrorObject, Provider } from 'shared/api/helpers'

export const TypeProjectsSchema = z.array(
z.union([
Expand All @@ -27,6 +28,8 @@ export const GoalsSchema = z.array(
const MeSchema = z.object({
owner: z.object({
defaultOrgUsername: z.string().nullable(),
ownerid: z.number().nullable(),
username: z.string().nullable(),
}),
email: z.string().nullable(),
privateAccess: z.boolean().nullable(),
Expand Down Expand Up @@ -80,6 +83,8 @@ const currentUserFragment = `
fragment CurrentUserFragment on Me {
owner {
defaultOrgUsername
ownerid
username
}
email
privateAccess
Expand Down Expand Up @@ -121,7 +126,7 @@ fragment CurrentUserFragment on Me {
`

interface URLParams {
provider: string
provider: Provider
}

interface UseUserArgs {
Expand All @@ -134,7 +139,6 @@ interface UseUserArgs {

export function useUser({ options }: UseUserArgs = {}) {
const { provider } = useParams<URLParams>()

const query = `
query CurrentUser {
me {
Expand All @@ -158,6 +162,16 @@ export function useUser({ options }: UseUserArgs = {}) {
} satisfies NetworkErrorObject)
}

if (
parsedRes.data.me?.owner.ownerid &&
parsedRes.data.me?.owner.username
) {
eventTracker().identify({
userOwnerId: parsedRes.data.me.owner.ownerid,
provider,
})
}

return parsedRes.data.me
}),
enabled: provider !== undefined,
Expand Down
Loading
Loading