Skip to content

Commit

Permalink
[Search] SearchBar Analytics tracking (#8656)
Browse files Browse the repository at this point in the history
* test capture search bar events

Signed-off-by: Emma Indal <emmai@spotify.com>

* capture search bar events

Signed-off-by: Emma Indal <emmai@spotify.com>

* only run onChange method if provided, otherwise set term

Signed-off-by: Emma Indal <emmai@spotify.com>

* changeset

Signed-off-by: Emma Indal <emmai@spotify.com>

* unconditionally call analytics API

Signed-off-by: Emma Indal <emmai@spotify.com>

* update changeset to be less specific to GA

Signed-off-by: Emma Indal <emmai@spotify.com>

* move analytics tracking to SearchBarBase

Signed-off-by: Emma Indal <emmai@spotify.com>

* add search to key events

Signed-off-by: Emma Indal <emmai@spotify.com>

* capture types as analytics context attribute, only for SearchBar

Signed-off-by: Emma Indal <emmai@spotify.com>

* move AnalyticsContext to within SearchContextProvider

Signed-off-by: Emma Indal <emmai@spotify.com>

* refactor search tracking out to its own component

Signed-off-by: Emma Indal <emmai@spotify.com>

* captures not only google analytics

Signed-off-by: Emma Indal <emmai@spotify.com>
  • Loading branch information
emmaindal committed Dec 29, 2021
1 parent e35aff0 commit af4980f
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .changeset/search-odd-starfishes-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---

Captures the search term entered in the SearchBarBase as a `search` event.
9 changes: 5 additions & 4 deletions docs/plugins/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ learn how to contribute the integration yourself!
The following table summarizes events that, depending on the plugins you have
installed, may be captured.

| Action | Provided By | Subject |
| ---------- | -------------- | ----------------------------------------- |
| `navigate` | Backstage Core | The URL of the page that was navigated to |
| `click` | Backstage Core | The text of the link that was clicked on |
| Action | Provided By | Subject |
| ---------- | -------------- | --------------------------------------------------- |
| `navigate` | Backstage Core | The URL of the page that was navigated to |
| `click` | Backstage Core | The text of the link that was clicked on |
| `search` | Backstage Core | The search term entered in any search bar component |

If there is an event you'd like to see captured, please [open an
issue][add-event] describing the event you want to see and the questions it
Expand Down
141 changes: 136 additions & 5 deletions plugins/search/src/components/SearchBar/SearchBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import userEvent from '@testing-library/user-event';
import { SearchContextProvider } from '../SearchContext';

import { SearchBar } from './SearchBar';
import { configApiRef } from '@backstage/core-plugin-api';
import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
import { searchApiRef } from '../../apis';
import { TestApiRegistry } from '@backstage/test-utils';
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';

jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
Expand All @@ -38,9 +38,16 @@ describe('SearchBar', () => {
};

const query = jest.fn().mockResolvedValue({});

const apiRegistry = TestApiRegistry.from(
[configApiRef, new ConfigReader({ app: { title: 'Mock title' } })],
const analyticsApiSpy = new MockAnalyticsApi();
let apiRegistry: TestApiRegistry;

apiRegistry = TestApiRegistry.from(
[
configApiRef,
new ConfigReader({
app: { title: 'Mock title' },
}),
],
[searchApiRef, { query }],
);

Expand Down Expand Up @@ -210,4 +217,128 @@ describe('SearchBar', () => {
expect.objectContaining({ term: value }),
);
});

it('does not capture analytics event if not enabled in app', async () => {
jest.useFakeTimers();

const debounceTime = 600;

render(
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={initialState}>
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>
,
</ApiProvider>,
);

await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
});

const textbox = screen.getByRole('textbox', { name });

const value = 'value';

userEvent.type(textbox, value);

act(() => {
jest.advanceTimersByTime(debounceTime);
});

await waitFor(() => expect(textbox).toHaveValue(value));

expect(analyticsApiSpy.getEvents()).toHaveLength(0);
});

it('captures analytics events if enabled in app', async () => {
jest.useFakeTimers();

const debounceTime = 600;

apiRegistry = TestApiRegistry.from(
[analyticsApiRef, analyticsApiSpy],
[
configApiRef,
new ConfigReader({
app: {
title: 'Mock title',
analytics: {
ga: {
trackingId: 'xyz123',
},
},
},
}),
],
[searchApiRef, { query }],
);

render(
<ApiProvider apis={apiRegistry}>
<SearchContextProvider
initialState={{
term: '',
types: ['techdocs', 'software-catalog'],
filters: {},
}}
>
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>
</ApiProvider>,
);

await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
});

const textbox = screen.getByRole('textbox', { name });

const value = 'value';

userEvent.type(textbox, value);

expect(analyticsApiSpy.getEvents()).toHaveLength(0);

act(() => {
jest.advanceTimersByTime(debounceTime);
});

await waitFor(() => expect(textbox).toHaveValue(value));

expect(analyticsApiSpy.getEvents()).toHaveLength(1);
expect(analyticsApiSpy.getEvents()[0]).toEqual({
action: 'search',
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'unknown',
searchTypes: 'software-catalog,techdocs',
},
subject: 'value',
});

userEvent.clear(textbox);

// make sure new term is captured
userEvent.type(textbox, 'new value');

act(() => {
jest.advanceTimersByTime(debounceTime);
});

await waitFor(() => expect(textbox).toHaveValue('new value'));

expect(analyticsApiSpy.getEvents()).toHaveLength(2);
expect(analyticsApiSpy.getEvents()[1]).toEqual({
action: 'search',
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'unknown',
searchTypes: 'software-catalog,techdocs',
},
subject: 'new value',
});
});
});
34 changes: 20 additions & 14 deletions plugins/search/src/components/SearchBar/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';

import { useSearch } from '../SearchContext';
import { TrackSearch } from '../SearchTracker';

/**
* Props for {@link SearchBarBase}.
Expand Down Expand Up @@ -119,18 +120,20 @@ export const SearchBarBase = ({
);

return (
<InputBase
data-testid="search-bar-next"
value={value}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
<TrackSearch>
<InputBase
data-testid="search-bar-next"
value={value}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
</TrackSearch>
);
};

Expand All @@ -150,8 +153,11 @@ export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
const { term, setTerm } = useSearch();

const handleChange = (newValue: string) => {
setTerm(newValue);
if (onChange) onChange(newValue);
if (onChange) {
onChange(newValue);
} else {
setTerm(newValue);
}
};

return <SearchBarBase value={term} onChange={handleChange} {...props} />;
Expand Down
8 changes: 6 additions & 2 deletions plugins/search/src/components/SearchContext/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { JsonObject } from '@backstage/types';
import { useApi } from '@backstage/core-plugin-api';
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchResultSet } from '@backstage/search-common';
import React, {
createContext,
Expand Down Expand Up @@ -130,7 +130,11 @@ export const SearchContextProvider = ({
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
};

return <SearchContext.Provider value={value} children={children} />;
return (
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
<SearchContext.Provider value={value} children={children} />
</AnalyticsContext>
);
};

export const useSearch = () => {
Expand Down
36 changes: 36 additions & 0 deletions plugins/search/src/components/SearchTracker/SearchTracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React, { useEffect } from 'react';
import { useAnalytics } from '@backstage/core-plugin-api';
import { useSearch } from '../SearchContext';

/**
* Capture search event on term change.
*/
export const TrackSearch = ({ children }: { children: React.ReactChild }) => {
const analytics = useAnalytics();
const { term } = useSearch();

useEffect(() => {
if (term) {
// Capture analytics search event with search term provided as value
analytics.captureEvent('search', term);
}
}, [analytics, term]);

return <>{children}</>;
};
16 changes: 16 additions & 0 deletions plugins/search/src/components/SearchTracker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TrackSearch } from './SearchTracker';

0 comments on commit af4980f

Please sign in to comment.