[Search] SearchBar Analytics tracking (#8656)

* 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>
This commit is contained in:
Emma Indal
2021-12-29 14:10:01 +01:00
committed by GitHub
parent e35aff0d16
commit af4980fb5d
7 changed files with 223 additions and 24 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Captures the search term entered in the SearchBarBase as a `search` event.
+5 -4
View File
@@ -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
@@ -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'),
@@ -38,9 +38,16 @@ describe('SearchBar', () => {
};
const query = jest.fn().mockResolvedValue({});
const analyticsApiSpy = new MockAnalyticsApi();
let apiRegistry: TestApiRegistry;
const apiRegistry = TestApiRegistry.from(
[configApiRef, new ConfigReader({ app: { title: 'Mock title' } })],
apiRegistry = TestApiRegistry.from(
[
configApiRef,
new ConfigReader({
app: { title: 'Mock title' },
}),
],
[searchApiRef, { query }],
);
@@ -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',
});
});
});
@@ -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}.
@@ -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>
);
};
@@ -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} />;
@@ -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,
@@ -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 = () => {
@@ -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}</>;
};
@@ -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';