Merge pull request #11951 from backstage/search/track-result-clicks

[Search] Track `discover` event and result rank
This commit is contained in:
Eric Peterson
2022-06-10 16:08:29 +02:00
committed by GitHub
24 changed files with 231 additions and 47 deletions
+50
View File
@@ -0,0 +1,50 @@
---
'@backstage/create-app': patch
---
It's now possible to pass result item components a `rank`, which is captured by the analytics API when a user clicks on a search result. To apply this change, update your `/packages/app/src/components/search/SearchPage.tsx` in the following way:
```diff
// ...
<SearchResult>
{({ results }) => (
<List>
- {results.map(({ type, document, highlight }) => {
+ {results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
+ rank={rank}
/>
);
case 'techdocs':
return (
<TechDocsSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
+ rank={rank}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
+ rank={rank}
/>
);
}
})}
</List>
)}
</SearchResult>
// ...
```
If you have implemented a custom Search Modal or other custom search experience, you will want to make similar changes in those components.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-common': patch
---
Added an optional `rank` attribute to the `Result` type. This represents the result rank (starting at 1) for a given result in a result set for a given search.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-search-backend': patch
'@backstage/plugin-search-backend-module-elasticsearch': patch
'@backstage/plugin-search-backend-module-pg': patch
'@backstage/plugin-search-backend-node': patch
---
The provided search engine now adds a pagination-aware `rank` value to all results.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-search': patch
'@backstage/plugin-techdocs': patch
---
In order to simplify analytics on top of the search experience in Backstage, the provided `<*ResultListItem />` component now captures a `discover` analytics event instead of a `click` event. This event includes the result rank as its `value` and, like a click, the URL/path clicked to as its `to` attribute.
+6 -5
View File
@@ -52,11 +52,12 @@ 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 |
| `search` | Backstage Core | The search term entered in any search bar component |
| Action | Subject | Other Notes |
| ---------- | --------------------------------------------------- | ----------------------------------------------------------------- |
| `navigate` | The URL of the page that was navigated to | |
| `click` | The text of the link that was clicked on | The `to` attribute represents the URL clicked to |
| `search` | The search term entered in any search bar component | The `searchTypes` attribute holds `types` constraining the search |
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided |
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
@@ -184,7 +184,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight }) => {
{results.map(({ type, document, highlight, rank }) => {
let resultItem;
switch (type) {
case 'software-catalog':
@@ -194,6 +194,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
@@ -204,6 +205,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
@@ -213,6 +215,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
@@ -132,7 +132,7 @@ const SearchPage = () => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight }) => {
{results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
@@ -141,6 +141,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'techdocs':
@@ -150,6 +151,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
@@ -158,6 +160,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
@@ -112,7 +112,7 @@ const SearchPage = () => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight }) => {
{results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
@@ -120,6 +120,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'techdocs':
@@ -128,6 +129,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
@@ -136,6 +138,7 @@ const SearchPage = () => {
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
+2
View File
@@ -114,6 +114,8 @@ export interface CatalogSearchResultListItemProps {
// (undocumented)
icon?: ReactNode;
// (undocumented)
rank?: number;
// (undocumented)
result: IndexableDocument;
}
@@ -25,6 +25,7 @@ import {
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import {
IndexableDocument,
ResultHighlight,
@@ -51,6 +52,7 @@ export interface CatalogSearchResultListItemProps {
icon?: ReactNode;
result: IndexableDocument;
highlight?: ResultHighlight;
rank?: number;
}
/** @public */
@@ -60,8 +62,16 @@ export function CatalogSearchResultListItem(
const result = props.result as any;
const classes = useStyles();
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: props.rank,
});
};
return (
<Link to={result.location}>
<Link noTrack to={result.location} onClick={handleClick}>
<ListItem alignItems="flex-start">
{props.icon && <ListItemIcon>{props.icon}</ListItemIcon>}
<div className={classes.flexContainer}>
@@ -523,6 +523,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
})),
),
});
@@ -567,6 +568,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
})),
),
nextPageCursor: 'MQ==',
@@ -614,6 +616,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
}))
.slice(25),
),
@@ -667,6 +670,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
rank: i + 1,
highlight: {
preTag: '<tag>',
postTag: '</tag>',
@@ -334,27 +334,30 @@ export class ElasticSearchSearchEngine implements SearchEngine {
: undefined;
return {
results: result.body.hits.hits.map((d: ElasticSearchResult) => {
const resultItem: IndexableResult = {
type: this.getTypeFromIndex(d._index),
document: d._source,
};
if (d.highlight) {
resultItem.highlight = {
preTag: this.highlightOptions.preTag as string,
postTag: this.highlightOptions.postTag as string,
fields: Object.fromEntries(
Object.entries(d.highlight).map(([field, fragments]) => [
field,
fragments.join(this.highlightOptions.fragmentDelimiter),
]),
),
results: result.body.hits.hits.map(
(d: ElasticSearchResult, index: number) => {
const resultItem: IndexableResult = {
type: this.getTypeFromIndex(d._index),
document: d._source,
rank: pageSize * page + index + 1,
};
}
return resultItem;
}),
if (d.highlight) {
resultItem.highlight = {
preTag: this.highlightOptions.preTag as string,
postTag: this.highlightOptions.postTag as string,
fields: Object.fromEntries(
Object.entries(d.highlight).map(([field, fragments]) => [
field,
fragments.join(this.highlightOptions.fragmentDelimiter),
]),
),
};
}
return resultItem;
},
),
nextPageCursor,
previousPageCursor,
};
@@ -174,6 +174,7 @@ describe('PgSearchEngine', () => {
location: 'location-1',
},
type: 'my-type',
rank: 1,
},
],
nextPageCursor: undefined,
@@ -215,6 +216,7 @@ describe('PgSearchEngine', () => {
location: `location-${i}`,
},
type: 'my-type',
rank: i + 1,
})),
nextPageCursor: 'MQ==',
});
@@ -257,6 +259,7 @@ describe('PgSearchEngine', () => {
location: `location-${i}`,
},
type: 'my-type',
rank: i + 1,
}))
.slice(25),
previousPageCursor: 'MA==',
@@ -18,6 +18,7 @@ import { SearchEngine } from '@backstage/plugin-search-backend-node';
import {
SearchQuery,
IndexableResultSet,
IndexableResult,
} from '@backstage/plugin-search-common';
import { PgSearchEngineIndexer } from './PgSearchEngineIndexer';
import {
@@ -104,10 +105,13 @@ export class PgSearchEngine implements SearchEngine {
? encodePageCursor({ page: page - 1 })
: undefined;
const results = pageRows.map(({ type, document }) => ({
type,
document,
}));
const results = pageRows.map(
({ type, document }, index): IndexableResult => ({
type,
document,
rank: page * pageSize + index + 1,
}),
);
return { results, nextPageCursor, previousPageCursor };
}
@@ -431,6 +431,7 @@ describe('LunrSearchEngine', () => {
location: 'test/location',
},
type: 'test-index',
rank: 1,
},
],
nextPageCursor: undefined,
@@ -469,6 +470,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -521,6 +523,7 @@ describe('LunrSearchEngine', () => {
location: `${highlightTags.pre}test/location${highlightTags.post}`,
},
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -559,6 +562,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -598,6 +602,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -637,6 +642,7 @@ describe('LunrSearchEngine', () => {
text: 'Hello World.',
location: 'test/location',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -676,6 +682,7 @@ describe('LunrSearchEngine', () => {
text: 'Searching',
location: 'test/location',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -722,6 +729,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location2',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -777,6 +785,7 @@ describe('LunrSearchEngine', () => {
location: 'test/location2',
extraField: 'testExtraField',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -823,6 +832,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test:location2',
},
rank: 1,
},
],
nextPageCursor: undefined,
@@ -886,6 +896,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
title: 'testTitle',
},
rank: 1,
},
{
document: {
@@ -893,6 +904,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
title: 'testTitle',
},
rank: 2,
},
],
nextPageCursor: undefined,
@@ -931,6 +943,7 @@ describe('LunrSearchEngine', () => {
location: `test/location/${i}`,
},
type: 'test-index',
rank: i + 1,
})),
nextPageCursor: 'MQ==',
previousPageCursor: undefined,
@@ -968,6 +981,7 @@ describe('LunrSearchEngine', () => {
location: `test/location/${i}`,
},
type: 'test-index',
rank: i + 1,
}))
.slice(25),
nextPageCursor: undefined,
@@ -207,9 +207,10 @@ export class LunrSearchEngine implements SearchEngine {
// Translate results into IndexableResultSet
const realResultSet: IndexableResultSet = {
results: results.slice(offset, offset + pageSize).map(d => ({
results: results.slice(offset, offset + pageSize).map((d, index) => ({
type: d.type,
document: this.docStore[d.result.ref],
rank: page * pageSize + index + 1,
highlight: {
preTag: this.highlightPreTag,
postTag: this.highlightPostTag,
@@ -44,6 +44,7 @@ describe('AuthorizedSearchEngine', () => {
.fill(0)
.map((_, index) => ({
type,
rank: index + 1,
document: {
title: `${type}_doc_${index}`,
authorization: withAuthorization
@@ -261,9 +262,11 @@ describe('AuthorizedSearchEngine', () => {
}),
);
const expectedResult = { ...usersWithAuth[8], rank: 1 };
await expect(
authorizedSearchEngine.query({ term: '' }, options),
).resolves.toEqual({ results: [usersWithAuth[8]] });
).resolves.toEqual({ results: [expectedResult] });
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users'] },
@@ -275,6 +278,7 @@ describe('AuthorizedSearchEngine', () => {
const searchResults = [
{
type: 'templates',
rank: 1,
document: {
title: `doc_0_a`,
authorization: { resourceRef: `template_doc_0` },
@@ -282,6 +286,7 @@ describe('AuthorizedSearchEngine', () => {
},
{
type: 'templates',
rank: 2,
document: {
title: `doc_0_b`,
authorization: { resourceRef: `template_doc_0` },
@@ -419,7 +424,9 @@ describe('AuthorizedSearchEngine', () => {
{ token: 'token' },
);
const expectedResult = allDocuments.slice(0, 25);
const expectedResult = allDocuments
.slice(0, 25)
.map((r, i) => ({ ...r, rank: i + 1 }));
const expectedFirstRequestCursor = 'MQ==';
expect(result).toEqual({
@@ -510,7 +517,8 @@ describe('AuthorizedSearchEngine', () => {
const expectedResult = allDocuments
.filter(d => d.type !== typeServices)
.slice(0, 25);
.slice(0, 25)
.map((d, i) => ({ ...d, rank: i + 1 }));
const expectedFirstRequestCursor = 'MQ==';
expect(result).toEqual({
@@ -587,8 +595,12 @@ describe('AuthorizedSearchEngine', () => {
{ token: 'token' },
);
const expectedResults = servicesWithAuth
.slice(5)
.map((r, i) => ({ ...r, rank: 25 + i + 1 }));
expect(result).toEqual({
results: servicesWithAuth.slice(5),
results: expectedResults,
previousPageCursor: encodePageCursor({ page: 0 }),
});
});
@@ -189,10 +189,15 @@ export class AuthorizedSearchEngine implements SearchEngine {
);
return {
results: filteredResults.slice(
page * this.pageSize,
(page + 1) * this.pageSize,
),
results: filteredResults
.slice(page * this.pageSize, (page + 1) * this.pageSize)
.map((result, index) => {
// Overwrite any/all rank entries to avoid leaking knowledge of filtered results.
return {
...result,
rank: page * this.pageSize + index + 1,
};
}),
previousPageCursor:
page === 0 ? undefined : encodePageCursor({ page: page - 1 }),
nextPageCursor:
+1 -3
View File
@@ -52,11 +52,9 @@ export type QueryTranslator = (query: SearchQuery) => unknown;
// @public (undocumented)
export interface Result<TDocument extends SearchDocument> {
// (undocumented)
document: TDocument;
// (undocumented)
highlight?: ResultHighlight;
// (undocumented)
rank?: number;
type: string;
}
+18
View File
@@ -56,9 +56,27 @@ export interface ResultHighlight {
* @public
*/
export interface Result<TDocument extends SearchDocument> {
/**
* The "type" of the given document. See: {@link DocumentCollatorFactory."type"}
*/
type: string;
/**
* The raw value of the document, as indexed.
*/
document: TDocument;
/**
* Optional result highlight. Useful for improving the search result
* display/experience.
*/
highlight?: ResultHighlight;
/**
* Optional result rank, where 1 is the first/top result returned. Useful for
* understanding search effectiveness in analytics.
*/
rank?: number;
}
/**
+1
View File
@@ -38,6 +38,7 @@ export type DefaultResultListItemProps = {
secondaryAction?: ReactNode;
result: SearchDocument;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
};
@@ -15,7 +15,7 @@
*/
import React, { ReactNode } from 'react';
import { AnalyticsContext } from '@backstage/core-plugin-api';
import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import {
ResultHighlight,
SearchDocument,
@@ -40,6 +40,7 @@ export type DefaultResultListItemProps = {
secondaryAction?: ReactNode;
result: SearchDocument;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
};
@@ -51,12 +52,21 @@ export type DefaultResultListItemProps = {
export const DefaultResultListItemComponent = ({
result,
highlight,
rank,
icon,
secondaryAction,
lineClamp = 5,
}: DefaultResultListItemProps) => {
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: rank,
});
};
return (
<Link to={result.location}>
<Link noTrack to={result.location} onClick={handleClick}>
<ListItem alignItems="center">
{icon && <ListItemIcon>{icon}</ListItemIcon>}
<ListItemText
+1
View File
@@ -393,6 +393,7 @@ export type TechDocsSearchResultListItemProps = {
icon?: ReactNode;
result: any;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
asListItem?: boolean;
asLink?: boolean;
@@ -23,6 +23,7 @@ import {
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
@@ -45,6 +46,7 @@ export type TechDocsSearchResultListItemProps = {
icon?: ReactNode;
result: any;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
asListItem?: boolean;
asLink?: boolean;
@@ -62,6 +64,7 @@ export const TechDocsSearchResultListItem = (
const {
result,
highlight,
rank,
lineClamp = 5,
asListItem = true,
asLink = true,
@@ -69,6 +72,15 @@ export const TechDocsSearchResultListItem = (
icon,
} = props;
const classes = useStyles();
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: rank,
});
};
const TextItem = () => {
const resultTitle = highlight?.fields.title ? (
<HighlightedSearchResultText
@@ -138,7 +150,13 @@ export const TechDocsSearchResultListItem = (
};
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
asLink ? <Link to={result.location}>{children}</Link> : <>{children}</>;
asLink ? (
<Link noTrack to={result.location} onClick={handleClick}>
{children}
</Link>
) : (
<>{children}</>
);
const ListItemWrapper = ({ children }: PropsWithChildren<{}>) =>
asListItem ? (