diff --git a/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts b/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts index 06e1e4ac8b..7daa60a167 100644 --- a/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts +++ b/packages/@ourworldindata/grapher/src/core/GrapherAnalytics.ts @@ -24,6 +24,8 @@ export enum EventCategory { SiteInstantSearchClick = "owid.site_instantsearch_click", SiteFormSubmit = "owid.site_form_submit", DetailOnDemand = "owid.detail_on_demand", + DataCatalogSearch = "owid.data_catalog_search", + DataCatalogResultClick = "owid.data_catalog_result_click", } enum EventAction { diff --git a/site/DataCatalog/DataCatalog.tsx b/site/DataCatalog/DataCatalog.tsx index a31fa1b734..9283bc6bff 100644 --- a/site/DataCatalog/DataCatalog.tsx +++ b/site/DataCatalog/DataCatalog.tsx @@ -61,6 +61,9 @@ import { SMALL_BREAKPOINT_MEDIA_QUERY, TOUCH_DEVICE_MEDIA_QUERY, } from "../SiteConstants.js" +import { SiteAnalytics } from "../SiteAnalytics.js" + +const analytics = new SiteAnalytics() const DataCatalogSearchInput = ({ value, @@ -429,6 +432,12 @@ const DataCatalogRibbon = ({ > { + analytics.logDataCatalogResultClick( + hit, + "ribbon" + ) + }} searchQueryRegionsMatches={selectedCountries} /> @@ -554,6 +563,12 @@ const DataCatalogResults = ({ key={hit.objectID} > { + analytics.logDataCatalogResultClick( + hit, + "search" + ) + }} hit={hit} searchQueryRegionsMatches={selectedCountries} /> @@ -836,6 +851,7 @@ export const DataCatalog = ({ } syncDataCatalogURL(stateAsUrl) + analytics.logDataCatalogSearch(state) if (cache.current[cacheKey].has(stateAsUrl)) return setIsLoading(true) diff --git a/site/DataCatalog/DataCatalogUtils.ts b/site/DataCatalog/DataCatalogUtils.ts index 8e498cd396..9bf61aafdf 100644 --- a/site/DataCatalog/DataCatalogUtils.ts +++ b/site/DataCatalog/DataCatalogUtils.ts @@ -1,6 +1,12 @@ -import { SearchResponse } from "instantsearch.js" +import { + HitAttributeHighlightResult, + HitAttributeSnippetResult, + HitHighlightResult, + SearchForFacetValuesResponse, + SearchResponse, +} from "instantsearch.js" import { getIndexName } from "../search/searchClient.js" -import { IChartHit, SearchIndexName } from "../search/searchTypes.js" +import { SearchIndexName } from "../search/searchTypes.js" import { TagGraphRoot } from "@ourworldindata/types" import { DataCatalogState, dataCatalogStateToUrl } from "./DataCatalogState.js" import { countriesByName, Region } from "@ourworldindata/utils" @@ -11,13 +17,41 @@ import { SearchClient } from "algoliasearch" */ const CHARTS_INDEX = getIndexName(SearchIndexName.Charts) +const DATA_CATALOG_ATTRIBUTES = [ + "title", + "slug", + "availableEntities", + "variantName", +] + /** * Types */ -export type DataCatalogHit = { +// This is a type that algolia doesn't export but is necessary to work with the algolia client +// Effectively the same as Awaited>, but generic +type MultipleQueriesResponse = { + results: Array | SearchForFacetValuesResponse> +} + +// This is the type for the hits that we get back from algolia when we search +// response.results[0].hits is an array of these +export type IDataCatalogHit = { title: string slug: string availableEntities: string[] + objectID: string + variantName: string | null + __position: number + _highlightResult?: HitHighlightResult + _snippetResult?: HitHighlightResult +} + +// SearchResponse adds the extra fields from Algolia: page, nbHits, etc +export type DataCatalogSearchResult = SearchResponse + +// We add a title field to the SearchResponse for the ribbons +export type DataCatalogRibbonResult = SearchResponse & { + title: string } export type DataCatalogCache = { @@ -25,12 +59,6 @@ export type DataCatalogCache = { search: Map } -export type DataCatalogSearchResult = SearchResponse - -export type DataCatalogRibbonResult = SearchResponse & { - title: string -} - /** * Utils */ @@ -128,14 +156,9 @@ export function dataCatalogStateToAlgoliaQueries( const facetFilters = [[`tags:${topic}`], ...countryFacetFilters] return { indexName: CHARTS_INDEX, + attributesToRetrieve: DATA_CATALOG_ATTRIBUTES, query: state.query, facetFilters: facetFilters, - attributesToRetrieve: [ - "title", - "slug", - "availableEntities", - "variantName", - ], hitsPerPage: 4, facets: ["tags"], page: state.page, @@ -153,14 +176,9 @@ export function dataCatalogStateToAlgoliaQuery(state: DataCatalogState) { return [ { indexName: CHARTS_INDEX, + attributesToRetrieve: DATA_CATALOG_ATTRIBUTES, query: state.query, facetFilters: facetFilters, - attributesToRetrieve: [ - "title", - "slug", - "availableEntities", - "variantName", - ], highlightPostTag: "", highlightPreTag: "", facets: ["tags"], @@ -172,22 +190,25 @@ export function dataCatalogStateToAlgoliaQuery(state: DataCatalogState) { } export function formatAlgoliaRibbonsResponse( - response: any, + response: MultipleQueriesResponse, ribbonTopics: string[] ): DataCatalogRibbonResult[] { - return response.results.map( - (res: SearchResponse, i: number) => ({ - ...res, - title: ribbonTopics[i], - }) - ) + return response.results.map((res, i: number) => ({ + ...(res as SearchResponse), + title: ribbonTopics[i], + })) } export function formatAlgoliaSearchResponse( - response: any + response: MultipleQueriesResponse ): DataCatalogSearchResult { + const result = response.results[0] as SearchResponse return { - ...response.results[0], + ...result, + hits: result.hits.map((hit, i) => ({ + ...hit, + __position: i + 1, + })), } } @@ -206,7 +227,7 @@ export async function queryRibbonsWithCache( topicsForRibbons ) return searchClient - .search(searchParams) + .search(searchParams) .then((response) => formatAlgoliaRibbonsResponse(response, topicsForRibbons) ) @@ -222,7 +243,7 @@ export async function querySearchWithCache( ): Promise { const searchParams = dataCatalogStateToAlgoliaQuery(state) return searchClient - .search(searchParams) + .search(searchParams) .then(formatAlgoliaSearchResponse) .then((formatted) => { cache.current.search.set(dataCatalogStateToUrl(state), formatted) diff --git a/site/SiteAnalytics.ts b/site/SiteAnalytics.ts index cea8be06f5..1fd2449c7c 100644 --- a/site/SiteAnalytics.ts +++ b/site/SiteAnalytics.ts @@ -1,5 +1,7 @@ import { GrapherAnalytics, EventCategory } from "@ourworldindata/grapher" import { SearchCategoryFilter } from "./search/searchTypes.js" +import { DataCatalogState } from "./DataCatalog/DataCatalogState.js" +import { IDataCatalogHit } from "./DataCatalog/DataCatalogUtils.js" export class SiteAnalytics extends GrapherAnalytics { logCountryProfileSearch(country: string) { @@ -89,4 +91,31 @@ export class SiteAnalytics extends GrapherAnalytics { eventTarget: id, }) } + + logDataCatalogSearch(state: DataCatalogState) { + this.logToGA({ + event: EventCategory.DataCatalogSearch, + eventAction: "search", + eventContext: JSON.stringify({ + ...state, + topics: Array.from(state.topics), + selectedCountryNames: Array.from(state.selectedCountryNames), + }), + }) + } + + logDataCatalogResultClick( + hit: IDataCatalogHit, + source: "ribbon" | "search" + ) { + this.logToGA({ + event: EventCategory.DataCatalogResultClick, + eventAction: "click", + eventContext: JSON.stringify({ + position: hit.__position, + source, + }), + eventTarget: hit.slug, + }) + } } diff --git a/site/search/ChartHit.tsx b/site/search/ChartHit.tsx index a2ec826e6f..9e3c7ec7c3 100644 --- a/site/search/ChartHit.tsx +++ b/site/search/ChartHit.tsx @@ -20,13 +20,18 @@ import { DEFAULT_GRAPHER_WIDTH, } from "@ourworldindata/grapher" import { Highlight } from "react-instantsearch" +import { IDataCatalogHit } from "../DataCatalog/DataCatalogUtils.js" export function ChartHit({ hit, searchQueryRegionsMatches, + onClick, }: { - hit: IChartHit + hit: IChartHit | IDataCatalogHit searchQueryRegionsMatches?: Region[] | undefined + // Search uses a global onClick handler to track analytics + // But the data catalog passes a function to this component explicitly + onClick?: () => void }) { const [imgLoaded, setImgLoaded] = useState(false) const [imgError, setImgError] = useState(false) @@ -60,6 +65,7 @@ export function ChartHit({