[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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Captures the search term entered in the SearchBarBase as a `search` event.
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user