Skip to content

Commit a5dcca4

Browse files
feat: Implement EventTracker abstraction and Amplitude implementation (#3650)
1 parent a3390e6 commit a5dcca4

File tree

24 files changed

+1339
-47
lines changed

24 files changed

+1339
-47
lines changed

src/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Router } from 'react-router-dom'
1212
import { CompatRouter } from 'react-router-dom-v5-compat'
1313

1414
import ErrorBoundary from 'layouts/shared/ErrorBoundary'
15+
import { initEventTracker } from 'services/events/events'
1516
import { withFeatureFlagProvider } from 'shared/featureFlags'
1617

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

3940
const TOO_MANY_REQUESTS_ERROR_CODE = 429
4041

42+
initEventTracker()
4143
setupSentry({ history })
4244

4345
const queryClient = new QueryClient({

src/layouts/BaseLayout/BaseLayout.test.tsx

+24
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,22 @@ const mockNavigatorData = {
170170
},
171171
}
172172

173+
const mockOwnerContext = {
174+
owner: {
175+
ownerid: 123,
176+
},
177+
}
178+
179+
const mockRepoContext = {
180+
owner: {
181+
repository: {
182+
__typename: 'Repository',
183+
repoid: 321,
184+
private: false,
185+
},
186+
},
187+
}
188+
173189
const server = setupServer()
174190
const queryClient = new QueryClient({
175191
defaultOptions: {
@@ -288,6 +304,14 @@ describe('BaseLayout', () => {
288304
graphql.query('NavigatorData', () => {
289305
return HttpResponse.json({ data: mockNavigatorData })
290306
}),
307+
graphql.query('OwnerContext', () => {
308+
return HttpResponse.json({ data: mockOwnerContext })
309+
}),
310+
graphql.query('RepoContext', () => {
311+
return HttpResponse.json({
312+
data: mockRepoContext,
313+
})
314+
}),
291315
http.get('/internal/users/current', () => {
292316
return HttpResponse.json({})
293317
})

src/layouts/BaseLayout/BaseLayout.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import NetworkErrorBoundary from 'layouts/shared/NetworkErrorBoundary'
1010
import SilentNetworkErrorWrapper from 'layouts/shared/SilentNetworkErrorWrapper'
1111
import ToastNotifications from 'layouts/ToastNotifications'
1212
import { RepoBreadcrumbProvider } from 'pages/RepoPage/context'
13+
import { useEventContext } from 'services/events/hooks'
1314
import { useImpersonate } from 'services/impersonate'
1415
import { useTracking } from 'services/tracking'
1516
import GlobalBanners from 'shared/GlobalBanners'
@@ -77,6 +78,7 @@ interface URLParams {
7778
function BaseLayout({ children }: React.PropsWithChildren) {
7879
const { provider, owner, repo } = useParams<URLParams>()
7980
useTracking()
81+
useEventContext()
8082
const { isImpersonating } = useImpersonate()
8183
const {
8284
isFullExperience,

src/layouts/Header/components/UserDropdown/UserDropdown.test.tsx

+35
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type Mock } from 'vitest'
88

99
import config from 'config'
1010

11+
import { eventTracker } from 'services/events/events'
1112
import { useImage } from 'services/image'
1213
import { Plans } from 'shared/utils/billing'
1314

@@ -60,6 +61,7 @@ const mockUser = {
6061
vi.mock('services/image')
6162
vi.mock('config')
6263
vi.mock('js-cookie')
64+
vi.mock('services/events/events')
6365

6466
const queryClient = new QueryClient({
6567
defaultOptions: { queries: { retry: false } },
@@ -214,6 +216,39 @@ describe('UserDropdown', () => {
214216
'https://github.com/apps/codecov/installations/new'
215217
)
216218
})
219+
220+
describe('when app access link is clicked', () => {
221+
it('tracks a Button Clicked event', async () => {
222+
const { user } = setup()
223+
render(<UserDropdown />, {
224+
wrapper: wrapper(),
225+
})
226+
227+
expect(
228+
screen.queryByText('Install Codecov app')
229+
).not.toBeInTheDocument()
230+
231+
const openSelect = await screen.findByTestId('user-dropdown-trigger')
232+
await user.click(openSelect)
233+
234+
const link = screen.getByText('Install Codecov app')
235+
expect(link).toBeVisible()
236+
expect(link).toHaveAttribute(
237+
'href',
238+
'https://github.com/apps/codecov/installations/new'
239+
)
240+
241+
await user.click(link)
242+
243+
expect(eventTracker().track).toHaveBeenCalledWith({
244+
type: 'Button Clicked',
245+
properties: {
246+
buttonName: 'Install GitHub App',
247+
buttonLocation: 'User dropdown',
248+
},
249+
})
250+
})
251+
})
217252
})
218253
})
219254
describe('when not on GitHub', () => {

src/layouts/Header/components/UserDropdown/UserDropdown.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useHistory, useParams } from 'react-router-dom'
22

33
import config from 'config'
44

5+
import { eventTracker } from 'services/events/events'
56
import { useUser } from 'services/user'
67
import { Provider } from 'shared/api/helpers'
78
import { providerToName } from 'shared/utils/provider'
@@ -42,6 +43,14 @@ function UserDropdown() {
4243
{
4344
to: { pageName: 'codecovAppInstallation' },
4445
children: 'Install Codecov app',
46+
onClick: () =>
47+
eventTracker().track({
48+
type: 'Button Clicked',
49+
properties: {
50+
buttonName: 'Install GitHub App',
51+
buttonLocation: 'User dropdown',
52+
},
53+
}),
4554
} as DropdownItem,
4655
]
4756
: []

src/pages/OwnerPage/HeaderBanners/GithubConfigBanner/GithubConfigBanner.jsx

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useParams } from 'react-router-dom'
22

3+
import { eventTracker } from 'services/events/events'
34
import { providerToName } from 'shared/utils/provider'
45
import A from 'ui/A'
56
import Banner from 'ui/Banner'
@@ -21,6 +22,15 @@ const GithubConfigBanner = () => {
2122
<A
2223
data-testid="codecovGithubApp-link"
2324
to={{ pageName: 'codecovGithubAppSelectTarget' }}
25+
onClick={() =>
26+
eventTracker().track({
27+
type: 'Button Clicked',
28+
properties: {
29+
buttonName: 'Install GitHub App',
30+
buttonLocation: 'Configure GitHub app banner',
31+
},
32+
})
33+
}
2434
>
2535
Codecov&apos;s GitHub app
2636
</A>

src/pages/OwnerPage/HeaderBanners/GithubConfigBanner/GithubConfigBanner.test.jsx

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { render, screen } from '@testing-library/react'
1+
import { act, render, screen } from '@testing-library/react'
22
import { MemoryRouter, Route, Switch } from 'react-router-dom'
33

4+
import { eventTracker } from 'services/events/events'
5+
46
import GithubConfigBanner from './GithubConfigBanner'
57

8+
vi.mock('services/events/events')
9+
610
const wrapper =
711
({ provider = 'gh' }) =>
812
({ children }) => {
@@ -34,6 +38,27 @@ describe('GithubConfigBanner', () => {
3438
)
3539
expect(body).toBeInTheDocument()
3640
})
41+
42+
describe('and button is clicked', () => {
43+
it('tracks a Button Clicked event', async () => {
44+
render(<GithubConfigBanner />, {
45+
wrapper: wrapper({ provider: 'gh' }),
46+
})
47+
48+
const title = screen.getByText(/Codecov's GitHub app/)
49+
expect(title).toBeInTheDocument()
50+
51+
act(() => title.click())
52+
53+
expect(eventTracker().track).toHaveBeenCalledWith({
54+
type: 'Button Clicked',
55+
properties: {
56+
buttonName: 'Install GitHub App',
57+
buttonLocation: 'Configure GitHub app banner',
58+
},
59+
})
60+
})
61+
})
3762
})
3863

3964
describe('when rendered with other providers', () => {
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { EventTracker } from '../types'
2+
3+
//
4+
// Use this mock by
5+
// vi.mock('services/events/events')
6+
// and
7+
// expect(eventTracker().track).toHaveBeenCalledWith()
8+
//
9+
10+
const MOCK_EVENT_TRACKER: EventTracker = {
11+
identify: vi.fn(),
12+
track: vi.fn(),
13+
setContext: vi.fn(),
14+
}
15+
16+
export function eventTracker() {
17+
return MOCK_EVENT_TRACKER
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import config from 'config'
2+
3+
import { AmplitudeEventTracker, initAmplitude } from './amplitude'
4+
5+
const mockIdentifySet = vi.hoisted(() => vi.fn())
6+
const mockIdentifyConstructor = vi.hoisted(() => vi.fn())
7+
const mockAmplitude = vi.hoisted(() => {
8+
class MockIdentify {
9+
constructor() {
10+
mockIdentifyConstructor()
11+
}
12+
set(key: string, value: any) {
13+
mockIdentifySet(key, value)
14+
}
15+
}
16+
return {
17+
init: vi.fn(),
18+
track: vi.fn(),
19+
identify: vi.fn(),
20+
setUserId: vi.fn(),
21+
Identify: MockIdentify,
22+
}
23+
})
24+
vi.mock('@amplitude/analytics-browser', () => mockAmplitude)
25+
26+
afterEach(() => {
27+
vi.resetAllMocks()
28+
})
29+
30+
describe('when initAmplitude is called', () => {
31+
describe('and AMPLITUDE_API_KEY is not defined', () => {
32+
it('throws an error', () => {
33+
config.AMPLITUDE_API_KEY = undefined
34+
try {
35+
initAmplitude()
36+
} catch (e) {
37+
expect(e).toEqual(
38+
new Error(
39+
'AMPLITUDE_API_KEY is not defined. Amplitude events will not be tracked.'
40+
)
41+
)
42+
}
43+
})
44+
})
45+
46+
describe('and AMPLITUDE_API_KEY is defined', () => {
47+
it('calls amplitude.init() with api key', () => {
48+
config.AMPLITUDE_API_KEY = 'asdf1234'
49+
initAmplitude()
50+
expect(mockAmplitude.init).toHaveBeenCalled()
51+
})
52+
})
53+
})
54+
55+
describe('AmplitudeEventTracker', () => {
56+
describe('identify', () => {
57+
describe('when identify is called', () => {
58+
it('calls appropriate sdk functions', () => {
59+
const tracker = new AmplitudeEventTracker()
60+
tracker.identify({
61+
provider: 'gh',
62+
userOwnerId: 123,
63+
})
64+
expect(mockAmplitude.setUserId).toHaveBeenCalledWith('123')
65+
expect(mockIdentifyConstructor).toHaveBeenCalled()
66+
expect(mockIdentifySet).toHaveBeenCalledWith('provider', 'github')
67+
expect(mockAmplitude.identify).toHaveBeenCalled()
68+
expect(tracker.identity).toEqual({
69+
userOwnerId: 123,
70+
provider: 'gh',
71+
})
72+
})
73+
})
74+
75+
describe('when identify is called multiple times with the same identity', () => {
76+
it('does not make any amplitude calls', () => {
77+
const tracker = new AmplitudeEventTracker()
78+
tracker.identify({
79+
provider: 'gh',
80+
userOwnerId: 123,
81+
})
82+
83+
vi.resetAllMocks()
84+
85+
tracker.identify({
86+
provider: 'gh',
87+
userOwnerId: 123,
88+
})
89+
90+
expect(mockAmplitude.setUserId).not.toHaveBeenCalled()
91+
92+
expect(tracker.identity).toEqual({
93+
userOwnerId: 123,
94+
provider: 'gh',
95+
})
96+
})
97+
})
98+
})
99+
100+
describe('track', () => {
101+
describe('when track is called with no context', () => {
102+
it('does not populate any context', () => {
103+
const tracker = new AmplitudeEventTracker()
104+
tracker.track({
105+
type: 'Button Clicked',
106+
properties: {
107+
buttonName: 'Configure Repo',
108+
},
109+
})
110+
111+
expect(mockAmplitude.track).toHaveBeenCalledWith({
112+
event_type: 'Button Clicked',
113+
event_properties: {
114+
buttonName: 'Configure Repo',
115+
},
116+
})
117+
})
118+
})
119+
120+
describe('when track is called with context', () => {
121+
it('populates context as event properties', () => {
122+
const tracker = new AmplitudeEventTracker()
123+
tracker.setContext({
124+
owner: {
125+
id: 123,
126+
},
127+
repo: {
128+
id: 321,
129+
isPrivate: false,
130+
},
131+
})
132+
133+
tracker.track({
134+
type: 'Button Clicked',
135+
properties: {
136+
buttonName: 'Configure Repo',
137+
},
138+
})
139+
140+
expect(mockAmplitude.track).toHaveBeenCalledWith({
141+
event_type: 'Button Clicked',
142+
event_properties: {
143+
buttonName: 'Configure Repo',
144+
owner: {
145+
id: 123,
146+
},
147+
repo: {
148+
id: 321,
149+
isPrivate: false,
150+
},
151+
},
152+
groups: {
153+
org: 123,
154+
},
155+
})
156+
})
157+
})
158+
})
159+
})

0 commit comments

Comments
 (0)