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

Merged
merged 14 commits into from
Jan 22, 2025
Merged
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
24 changes: 24 additions & 0 deletions src/layouts/BaseLayout/BaseLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,22 @@ const mockNavigatorData = {
},
}

const mockOwnerContext = {
owner: {
ownerid: 123,
},
}

const mockRepoContext = {
owner: {
repository: {
__typename: 'Repository',
repoid: 321,
private: false,
},
},
}

const server = setupServer()
const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -288,6 +304,14 @@ describe('BaseLayout', () => {
graphql.query('NavigatorData', () => {
return HttpResponse.json({ data: mockNavigatorData })
}),
graphql.query('OwnerContext', () => {
return HttpResponse.json({ data: mockOwnerContext })
}),
graphql.query('RepoContext', () => {
return HttpResponse.json({
data: mockRepoContext,
})
}),
http.get('/internal/users/current', () => {
return HttpResponse.json({})
})
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/hooks'
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
35 changes: 35 additions & 0 deletions src/layouts/Header/components/UserDropdown/UserDropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type Mock } from 'vitest'

import config from 'config'

import { eventTracker } from 'services/events/events'
import { useImage } from 'services/image'
import { Plans } from 'shared/utils/billing'

Expand Down Expand Up @@ -60,6 +61,7 @@ const mockUser = {
vi.mock('services/image')
vi.mock('config')
vi.mock('js-cookie')
vi.mock('services/events/events')

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
Expand Down Expand Up @@ -214,6 +216,39 @@ describe('UserDropdown', () => {
'https://github.com/apps/codecov/installations/new'
)
})

describe('when app access link is clicked', () => {
it('tracks a Button Clicked event', async () => {
const { user } = setup()
render(<UserDropdown />, {
wrapper: wrapper(),
})

expect(
screen.queryByText('Install Codecov app')
).not.toBeInTheDocument()

const openSelect = await screen.findByTestId('user-dropdown-trigger')
await user.click(openSelect)

const link = screen.getByText('Install Codecov app')
expect(link).toBeVisible()
expect(link).toHaveAttribute(
'href',
'https://github.com/apps/codecov/installations/new'
)

await user.click(link)

expect(eventTracker().track).toHaveBeenCalledWith({
type: 'Button Clicked',
properties: {
buttonName: 'Install GitHub App',
buttonLocation: 'User dropdown',
},
})
})
})
})
})
describe('when not on GitHub', () => {
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: {
buttonName: '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: {
buttonName: 'Install GitHub App',
buttonLocation: 'Configure GitHub app banner',
},
})
}
>
Codecov&apos;s GitHub app
</A>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Switch } from 'react-router-dom'

import { eventTracker } from 'services/events/events'

import GithubConfigBanner from './GithubConfigBanner'

vi.mock('services/events/events')

const wrapper =
({ provider = 'gh' }) =>
({ children }) => {
Expand Down Expand Up @@ -34,6 +38,27 @@ describe('GithubConfigBanner', () => {
)
expect(body).toBeInTheDocument()
})

describe('and button is clicked', () => {
it('tracks a Button Clicked event', async () => {
render(<GithubConfigBanner />, {
wrapper: wrapper({ provider: 'gh' }),
})

const title = screen.getByText(/Codecov's GitHub app/)
expect(title).toBeInTheDocument()

act(() => title.click())

expect(eventTracker().track).toHaveBeenCalledWith({
type: 'Button Clicked',
properties: {
buttonName: 'Install GitHub App',
buttonLocation: 'Configure GitHub app banner',
},
})
})
})
})

describe('when rendered with other providers', () => {
Expand Down
18 changes: 18 additions & 0 deletions src/services/events/__mocks__/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { EventTracker } from '../types'

//
// Use this mock by
// vi.mock('services/events/events')
// and
// expect(eventTracker().track).toHaveBeenCalledWith()
//

const MOCK_EVENT_TRACKER: EventTracker = {
identify: vi.fn(),
track: vi.fn(),
setContext: vi.fn(),
}

export function eventTracker() {
return MOCK_EVENT_TRACKER
}
159 changes: 159 additions & 0 deletions src/services/events/amplitude/amplitude.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import config from 'config'

import { AmplitudeEventTracker, initAmplitude } from './amplitude'

const mockIdentifySet = vi.hoisted(() => vi.fn())
const mockIdentifyConstructor = vi.hoisted(() => vi.fn())
const mockAmplitude = vi.hoisted(() => {
class MockIdentify {
constructor() {
mockIdentifyConstructor()
}
set(key: string, value: any) {
mockIdentifySet(key, value)
}
}
return {
init: vi.fn(),
track: vi.fn(),
identify: vi.fn(),
setUserId: vi.fn(),
Identify: MockIdentify,
}
})
vi.mock('@amplitude/analytics-browser', () => mockAmplitude)

afterEach(() => {
vi.resetAllMocks()
})

describe('when initAmplitude is called', () => {
describe('and AMPLITUDE_API_KEY is not defined', () => {
it('throws an error', () => {
config.AMPLITUDE_API_KEY = undefined
try {
initAmplitude()
} catch (e) {
expect(e).toEqual(
new Error(
'AMPLITUDE_API_KEY is not defined. Amplitude events will not be tracked.'
)
)
}
})
})

describe('and AMPLITUDE_API_KEY is defined', () => {
it('calls amplitude.init() with api key', () => {
config.AMPLITUDE_API_KEY = 'asdf1234'
initAmplitude()
expect(mockAmplitude.init).toHaveBeenCalled()
})
})
})

describe('AmplitudeEventTracker', () => {
describe('identify', () => {
describe('when identify is called', () => {
it('calls appropriate sdk functions', () => {
const tracker = new AmplitudeEventTracker()
tracker.identify({
provider: 'gh',
userOwnerId: 123,
})
expect(mockAmplitude.setUserId).toHaveBeenCalledWith('123')
expect(mockIdentifyConstructor).toHaveBeenCalled()
expect(mockIdentifySet).toHaveBeenCalledWith('provider', 'github')
expect(mockAmplitude.identify).toHaveBeenCalled()
expect(tracker.identity).toEqual({
userOwnerId: 123,
provider: 'gh',
})
})
})

describe('when identify is called multiple times with the same identity', () => {
it('does not make any amplitude calls', () => {
const tracker = new AmplitudeEventTracker()
tracker.identify({
provider: 'gh',
userOwnerId: 123,
})

vi.resetAllMocks()

tracker.identify({
provider: 'gh',
userOwnerId: 123,
})

expect(mockAmplitude.setUserId).not.toHaveBeenCalled()

expect(tracker.identity).toEqual({
userOwnerId: 123,
provider: 'gh',
})
})
})
})

describe('track', () => {
describe('when track is called with no context', () => {
it('does not populate any context', () => {
const tracker = new AmplitudeEventTracker()
tracker.track({
type: 'Button Clicked',
properties: {
buttonName: 'Configure Repo',
},
})

expect(mockAmplitude.track).toHaveBeenCalledWith({
event_type: 'Button Clicked',
event_properties: {
buttonName: 'Configure Repo',
},
})
})
})

describe('when track is called with context', () => {
it('populates context as event properties', () => {
const tracker = new AmplitudeEventTracker()
tracker.setContext({
owner: {
id: 123,
},
repo: {
id: 321,
isPrivate: false,
},
})

tracker.track({
type: 'Button Clicked',
properties: {
buttonName: 'Configure Repo',
},
})

expect(mockAmplitude.track).toHaveBeenCalledWith({
event_type: 'Button Clicked',
event_properties: {
buttonName: 'Configure Repo',
owner: {
id: 123,
},
repo: {
id: 321,
isPrivate: false,
},
},
groups: {
org: 123,
},
})
})
})
})
})
Loading
Loading