diff --git a/examples/sandbox/package.json b/examples/sandbox/package.json index e67bfe9bd..fbf9a9fca 100644 --- a/examples/sandbox/package.json +++ b/examples/sandbox/package.json @@ -14,6 +14,7 @@ "dependencies": { "@elastic/apm-rum": "^5.12.0", "@elastic/apm-rum-react": "^1.4.2", + "@elastic/behavioral-analytics-javascript-tracker": "^2.1.3", "@elastic/datemath": "^5.0.3", "@elastic/eui": "^49.0.0", "@elastic/react-search-ui": "1.19.1", diff --git a/examples/sandbox/src/Router.js b/examples/sandbox/src/Router.js index 5853dae10..1bad99b15 100644 --- a/examples/sandbox/src/Router.js +++ b/examples/sandbox/src/Router.js @@ -2,6 +2,7 @@ import * as React from "react"; import { Switch } from "react-router-dom"; import Root from "./pages/root"; import Elasticsearch from "./pages/elasticsearch"; +import ElasticsearchWithAnalytics from "./pages/elasticsearch-with-analytics"; import Engines from "./pages/engines"; import AppSearch from "./pages/app-search"; import SiteSearch from "./pages/site-search"; @@ -50,6 +51,11 @@ export default function Router() { path="/search-bar-in-header/search" component={SearchBarInHeaderSearch} /> + {/* Use cases */} diff --git a/examples/sandbox/src/pages/elasticsearch-with-analytics/index.js b/examples/sandbox/src/pages/elasticsearch-with-analytics/index.js new file mode 100644 index 000000000..7b584b60b --- /dev/null +++ b/examples/sandbox/src/pages/elasticsearch-with-analytics/index.js @@ -0,0 +1,320 @@ +import React from "react"; +import "@elastic/eui/dist/eui_theme_light.css"; +import AnalyticsPlugin from "@elastic/search-ui-analytics-plugin"; +import { + createTracker, + getTracker +} from "@elastic/behavioral-analytics-javascript-tracker"; + +import ElasticSearchAPIConnector from "@elastic/search-ui-elasticsearch-connector"; +import moment from "moment"; + +import { + ErrorBoundary, + Facet, + SearchProvider, + SearchBox, + Results, + PagingInfo, + ResultsPerPage, + Paging, + Sorting, + WithSearch +} from "@elastic/react-search-ui"; +import { + BooleanFacet, + Layout, + SingleLinksFacet, + SingleSelectFacet +} from "@elastic/react-search-ui-views"; +import "@elastic/react-search-ui-views/lib/styles/styles.css"; + +createTracker({ + collectionName: "search-ui", + endpoint: "http://localhost:9200" +}); +const connector = new ElasticSearchAPIConnector({ + host: + process.env.REACT_ELASTICSEARCH_HOST || + "https://search-ui-sandbox.es.us-central1.gcp.cloud.es.io:9243", + index: process.env.REACT_ELASTICSEARCH_INDEX || "national-parks", + apiKey: + process.env.REACT_ELASTICSEARCH_API_KEY || + "SlUzdWE0QUJmN3VmYVF2Q0F6c0I6TklyWHFIZ3lTbHF6Yzc2eEtyeWFNdw==" +}); + +const config = { + debug: true, + alwaysSearchOnInitialLoad: true, + apiConnector: connector, + hasA11yNotifications: true, + plugins: [AnalyticsPlugin({ client: getTracker() })], + searchQuery: { + filters: [], + search_fields: { + title: { + weight: 3 + }, + description: {}, + states: {} + }, + result_fields: { + visitors: { raw: {} }, + world_heritage_site: { raw: {} }, + location: { raw: {} }, + acres: { raw: {} }, + square_km: { raw: {} }, + title: { + snippet: { + size: 100, + fallback: true + } + }, + nps_link: { raw: {} }, + states: { raw: {} }, + date_established: { raw: {} }, + image_url: { raw: {} }, + description: { + snippet: { + size: 100, + fallback: true + } + } + }, + disjunctiveFacets: [ + "acres", + "states.keyword", + "date_established", + "location" + ], + facets: { + "world_heritage_site.keyword": { type: "value" }, + "states.keyword": { type: "value", size: 30, sort: "count" }, + acres: { + type: "range", + ranges: [ + { from: -1, name: "Any" }, + { from: 0, to: 1000, name: "Small" }, + { from: 1001, to: 100000, name: "Medium" }, + { from: 100001, name: "Large" } + ] + }, + location: { + // San Francisco. In the future, make this the user's current position + center: "37.7749, -122.4194", + type: "range", + unit: "mi", + ranges: [ + { from: 0, to: 100, name: "Nearby" }, + { from: 100, to: 500, name: "A longer drive" }, + { from: 500, name: "Perhaps fly?" } + ] + }, + date_established: { + type: "range", + ranges: [ + { + from: moment().subtract(50, "years").toISOString(), + name: "Within the last 50 years" + }, + { + from: moment().subtract(100, "years").toISOString(), + to: moment().subtract(50, "years").toISOString(), + name: "50 - 100 years ago" + }, + { + to: moment().subtract(100, "years").toISOString(), + name: "More than 100 years ago" + } + ] + }, + visitors: { + type: "range", + ranges: [ + { from: 0, to: 10000, name: "0 - 10000" }, + { from: 10001, to: 100000, name: "10001 - 100000" }, + { from: 100001, to: 500000, name: "100001 - 500000" }, + { from: 500001, to: 1000000, name: "500001 - 1000000" }, + { from: 1000001, to: 5000000, name: "1000001 - 5000000" }, + { from: 5000001, to: 10000000, name: "5000001 - 10000000" }, + { from: 10000001, name: "10000001+" } + ] + } + } + }, + autocompleteQuery: { + results: { + search_fields: { + parks_search_as_you_type: {} + }, + resultsPerPage: 5, + result_fields: { + title: { + snippet: { + size: 100, + fallback: true + } + }, + nps_link: { + raw: {} + } + } + }, + suggestions: { + types: { + documents: { + fields: ["parks_completion"] + } + }, + size: 4 + } + } +}; + +const SORT_OPTIONS = [ + { + name: "Relevance", + value: [] + }, + { + name: "Title", + value: [ + { + field: "title.keyword", + direction: "asc" + } + ] + }, + { + name: "State", + value: [ + { + field: "states.keyword", + direction: "asc" + } + ] + }, + { + name: "State -> Title", + value: [ + { + field: "states.keyword", + direction: "asc" + }, + { + field: "title.keyword", + direction: "asc" + } + ] + }, + { + name: "Heritage Site -> State -> Title", + value: [ + { + field: "world_heritage_site.keyword", + direction: "asc" + }, + { + field: "states.keyword", + direction: "asc" + }, + { + field: "title.keyword", + direction: "asc" + } + ] + } +]; + +export default function App() { + return ( + + ({ + wasSearched + })} + > + {({ wasSearched }) => { + return ( +
+ + + } + sideContent={ +
+ {wasSearched && ( + + )} + + + + + + + +
+ } + bodyContent={ + + } + bodyHeader={ + + {wasSearched && } + {wasSearched && } + + } + bodyFooter={} + /> +
+
+ ); + }} +
+
+ ); +} diff --git a/examples/sandbox/src/pages/root.js b/examples/sandbox/src/pages/root.js index 95f77b0e4..c31ea6011 100644 --- a/examples/sandbox/src/pages/root.js +++ b/examples/sandbox/src/pages/root.js @@ -35,6 +35,11 @@ export default function Root() {
  • Search bar in header
  • +
  • + + Elasticsearch with analytics plugin + +
  • Explore use cases:

    diff --git a/packages/search-ui-analytics-plugin/package.json b/packages/search-ui-analytics-plugin/package.json index d1a2f7aba..f4b6e5d6c 100644 --- a/packages/search-ui-analytics-plugin/package.json +++ b/packages/search-ui-analytics-plugin/package.json @@ -40,5 +40,8 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@elastic/behavioral-analytics-tracker-core": "^2.0.5" } } diff --git a/packages/search-ui-analytics-plugin/src/__tests__/index.test.ts b/packages/search-ui-analytics-plugin/src/__tests__/index.test.ts index 1657247f6..6d7a16d55 100644 --- a/packages/search-ui-analytics-plugin/src/__tests__/index.test.ts +++ b/packages/search-ui-analytics-plugin/src/__tests__/index.test.ts @@ -24,7 +24,8 @@ describe("analytics plugin", () => { eaPlugin.subscribe({ type: "SearchQuery", query: "test", - totalResults: 0 + totalResults: 0, + filters: [] }); expect(window["elasticAnalytics"].trackEvent).not.toBeCalled(); expect(internalClient.trackEvent).toBeCalled(); @@ -36,12 +37,23 @@ describe("analytics plugin", () => { eaPlugin.subscribe({ type: "SearchQuery", query: "test", - totalResults: 0 + totalResults: 0, + filters: [] }); expect(window["elasticAnalytics"].trackEvent).toBeCalledWith("search", { - type: "SearchQuery", - query: "test", - totalResults: 0 + search: { + filters: {}, + page: { + current: undefined, + size: undefined + }, + results: { + items: [], + total_results: 0 + }, + sort: undefined, + query: "test" + } }); }); }); diff --git a/packages/search-ui-analytics-plugin/src/index.ts b/packages/search-ui-analytics-plugin/src/index.ts index 64e8e5f58..082ef0912 100644 --- a/packages/search-ui-analytics-plugin/src/index.ts +++ b/packages/search-ui-analytics-plugin/src/index.ts @@ -1,17 +1,104 @@ -import type { Event } from "@elastic/search-ui"; +import { + Event, + BaseEvent, + ResultSelectedEvent, + SearchQueryEvent, + FilterValueRange, + FilterValue +} from "@elastic/search-ui"; +import type { + Tracker, + TrackerEventType, + EventInputProperties, + SearchEventInputProperties, + SearchClickEventInputProperties +} from "@elastic/behavioral-analytics-tracker-core"; -export type EventType = "search" | "click" | "pageview"; +export interface AnalyticsPluginOptions { + client?: Pick; +} + +const transformSearchQueryEvent = (event: Omit) => { + const transformFilterValues = (values: FilterValue[]): string[] => { + const transformBasicValue = (value: string | boolean | number) => + value.toString(); + const transformRangeValue = (value: FilterValueRange) => + `${value.from || "*"}-${value.to || "*"}`; + + return values.reduce((res, value) => { + if (Array.isArray(value)) { + return [...res, ...value.map(transformBasicValue)]; + } -export type AnalyticsClient = { - trackEvent: (eventType: EventType, payload: Record) => void; + return [ + ...res, + typeof value === "object" + ? transformRangeValue(value) + : transformBasicValue(value) + ]; + }, []); + }; + + return { + search: { + query: event.query, + filters: event.filters.reduce( + (res, filter) => ({ + ...res, + [filter.field]: transformFilterValues(filter.values) + }), + {} + ), + page: { + current: event.currentPage, + size: event.resultsPerPage + }, + results: { + items: [], + total_results: event.totalResults + }, + sort: event.sort + ?.filter( + (sort) => sort.direction === "desc" || sort.direction === "asc" + ) + .map((sort) => ({ + name: sort.field, + direction: sort.direction as "asc" | "desc" + })) + } + }; }; -export interface AnalyticsPluginOptions { - client?: AnalyticsClient; -} +type TrackerParams< + T extends TrackerEventType, + K extends EventInputProperties +> = [T, K]; + +const mapEventToTrackerParams: Record< + ResultSelectedEvent["type"] | SearchQueryEvent["type"], + ( + event: BaseEvent + ) => + | TrackerParams<"search_click", SearchClickEventInputProperties> + | TrackerParams<"search", SearchEventInputProperties> +> = { + ResultSelected: (event: ResultSelectedEvent) => [ + "search_click", + { + ...transformSearchQueryEvent(event), + document: { id: event.documentId, index: event.origin } + } + ], + SearchQuery: (event: SearchQueryEvent) => [ + "search", + transformSearchQueryEvent(event) + ] +}; -export default function AnalyticsPlugin(options = { client: undefined }) { - const client: AnalyticsClient = +export default function AnalyticsPlugin( + options: AnalyticsPluginOptions = { client: undefined } +) { + const client: Tracker = options.client || (typeof window !== "undefined" && window["elasticAnalytics"]); if (!client) { @@ -22,15 +109,14 @@ export default function AnalyticsPlugin(options = { client: undefined }) { return { subscribe: (event: Event) => { - const eventTypeMap: Record = { - AutocompleteSuggestionSelected: "click", - FacetFilterRemoved: "click", - FacetFilterSelected: "click", - ResultSelected: "click", - SearchQuery: "search" - }; - - client.trackEvent(eventTypeMap[event.type], event); + const [eventType, payload] = + mapEventToTrackerParams[event.type](event) || []; + + if (!eventType || !payload) { + return; + } + + client.trackEvent(eventType, payload); } }; } diff --git a/packages/search-ui/src/SearchDriver.ts b/packages/search-ui/src/SearchDriver.ts index 4c1287c60..34ca31220 100644 --- a/packages/search-ui/src/SearchDriver.ts +++ b/packages/search-ui/src/SearchDriver.ts @@ -451,7 +451,11 @@ class SearchDriver { this.events.emit({ type: "SearchQuery", + filters: this.state.filters, query: this.state.searchTerm, + sort: requestState.sort, + currentPage: requestState.current, + resultsPerPage: requestState.resultsPerPage, totalResults: totalResults }); diff --git a/packages/search-ui/src/__tests__/Events.test.ts b/packages/search-ui/src/__tests__/Events.test.ts index 296aca822..670a0684a 100644 --- a/packages/search-ui/src/__tests__/Events.test.ts +++ b/packages/search-ui/src/__tests__/Events.test.ts @@ -139,7 +139,9 @@ describe("when an API connector and handler are both provided", () => { origin: "autocomplete", position: 1, query: "test", - tags: [] + tags: [], + filters: [], + totalResults: 0 }; events.emit(event); diff --git a/packages/search-ui/src/__tests__/SearchDriver.test.ts b/packages/search-ui/src/__tests__/SearchDriver.test.ts index d2a228a3f..9fe129568 100644 --- a/packages/search-ui/src/__tests__/SearchDriver.test.ts +++ b/packages/search-ui/src/__tests__/SearchDriver.test.ts @@ -521,8 +521,12 @@ describe("_updateSearchResults", () => { expect(stateAfterCreation.pagingStart).toEqual(21); expect(stateAfterCreation.pagingEnd).toEqual(40); expect(mockPlugin.subscribe).toBeCalledWith({ + currentPage: 1, query: "test", + resultsPerPage: 20, + sort: undefined, totalResults: 1000, + filters: [], type: "SearchQuery" }); }); diff --git a/packages/search-ui/src/__tests__/actions/trackAutocompleteClickThrough.test.ts b/packages/search-ui/src/__tests__/actions/trackAutocompleteClickThrough.test.ts index 246c2b9b3..27ff1c2c5 100644 --- a/packages/search-ui/src/__tests__/actions/trackAutocompleteClickThrough.test.ts +++ b/packages/search-ui/src/__tests__/actions/trackAutocompleteClickThrough.test.ts @@ -55,11 +55,16 @@ describe("#trackAutocompleteClickThrough", () => { expect(requestId).toEqual("6789"); expect(result._meta.content_source_id).toEqual("621581b6174a804659f9dc16"); expect(mockPlugin.subscribe).toBeCalledWith({ + currentPage: 1, documentId: "park_great-smoky-mountains", + filters: [], origin: "autocomplete", position: 0, query: "search terms", + resultsPerPage: 20, + sort: undefined, tags: ["test"], + totalResults: 0, type: "ResultSelected" }); }); diff --git a/packages/search-ui/src/__tests__/actions/trackClickThrough.test.ts b/packages/search-ui/src/__tests__/actions/trackClickThrough.test.ts index b2c5d8018..40a58ee9a 100644 --- a/packages/search-ui/src/__tests__/actions/trackClickThrough.test.ts +++ b/packages/search-ui/src/__tests__/actions/trackClickThrough.test.ts @@ -48,12 +48,17 @@ describe("#trackClickThrough", () => { expect(page).toBe(1); expect(mockPlugin.subscribe).toBeCalledWith({ + currentPage: 1, documentId: "park_great-smoky-mountains", + filters: [], origin: "results", position: 0, query: "search terms", + resultsPerPage: 20, + sort: undefined, tags: ["test"], - type: "ResultSelected" + type: "ResultSelected", + totalResults: 1000 }); }); }); diff --git a/packages/search-ui/src/actions/trackAutocompleteClickThrough.ts b/packages/search-ui/src/actions/trackAutocompleteClickThrough.ts index a911b7f1b..6ff14d1bf 100644 --- a/packages/search-ui/src/actions/trackAutocompleteClickThrough.ts +++ b/packages/search-ui/src/actions/trackAutocompleteClickThrough.ts @@ -22,8 +22,16 @@ export default function trackAutocompleteClickThrough( ); } - const { autocompletedResultsRequestId, searchTerm, autocompletedResults } = - this.state; + const { + autocompletedResultsRequestId, + searchTerm, + autocompletedResults, + current, + resultsPerPage, + totalResults, + filters, + sort + } = this.state; const resultIndex = autocompletedResults.findIndex( (result) => result._meta.id === documentId ); @@ -46,6 +54,11 @@ export default function trackAutocompleteClickThrough( query: searchTerm, position: resultIndex, origin: "autocomplete", - tags + tags, + totalResults, + filters, + sort, + currentPage: current, + resultsPerPage: resultsPerPage }); } diff --git a/packages/search-ui/src/actions/trackClickThrough.ts b/packages/search-ui/src/actions/trackClickThrough.ts index fa1c332e8..c952832e4 100644 --- a/packages/search-ui/src/actions/trackClickThrough.ts +++ b/packages/search-ui/src/actions/trackClickThrough.ts @@ -1,4 +1,4 @@ -import { SearchState } from ".."; +import { Event, SearchResult, SearchState } from ".."; import Events from "../Events"; /** @@ -23,7 +23,10 @@ export default function trackClickThrough( searchTerm, results, current, - resultsPerPage + resultsPerPage, + totalResults, + filters, + sort }: SearchState = this.state; const resultIndexOnPage = results.findIndex( @@ -47,8 +50,13 @@ export default function trackClickThrough( type: "ResultSelected", documentId, query: searchTerm, - position: resultIndexOnPage, origin: "results", - tags + position: resultIndexOnPage, + tags, + totalResults, + filters, + sort, + currentPage: current, + resultsPerPage: resultsPerPage }); } diff --git a/packages/search-ui/src/types/index.ts b/packages/search-ui/src/types/index.ts index ded003dea..76937c866 100644 --- a/packages/search-ui/src/types/index.ts +++ b/packages/search-ui/src/types/index.ts @@ -237,10 +237,14 @@ export interface BaseEvent { tags?: string[]; } -interface SearchQueryEvent extends BaseEvent { +export interface SearchQueryEvent extends BaseEvent { type: "SearchQuery"; + filters: Filter[]; query: string; totalResults: number; + sort?: SortOption[]; + currentPage?: number; + resultsPerPage?: number; } interface AutocompleteSuggestionSelectedEvent extends BaseEvent { @@ -250,9 +254,8 @@ interface AutocompleteSuggestionSelectedEvent extends BaseEvent { position: number; } -interface ResultSelectedEvent extends BaseEvent { +export interface ResultSelectedEvent extends Omit { type: "ResultSelected"; - query: string; documentId: string; position: number; origin: "autocomplete" | "results"; diff --git a/yarn.lock b/yarn.lock index ed22f8f93..a6534c801 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1621,6 +1621,18 @@ dependencies: object-hash "^1.3.0" +"@elastic/behavioral-analytics-javascript-tracker@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@elastic/behavioral-analytics-javascript-tracker/-/behavioral-analytics-javascript-tracker-2.1.3.tgz#53942a286c84ecc357dea0263f18882898a416e4" + integrity sha512-imProiNHyD04SLk8CqELv1E5quHnttoP7TWRzc94m0c8SxhU5vDn/5Af6JrRQ6QlGjc1+8ENckogcS1L9qGqaw== + dependencies: + "@elastic/behavioral-analytics-tracker-core" "2.0.5" + +"@elastic/behavioral-analytics-tracker-core@2.0.5", "@elastic/behavioral-analytics-tracker-core@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@elastic/behavioral-analytics-tracker-core/-/behavioral-analytics-tracker-core-2.0.5.tgz#831dfa87b01ba8808cd843c60fd49f909b915ad2" + integrity sha512-SEtxpTnnWz/Etve2cdwMyVEv1C67lrVX38wv1hvg6L6Vu7bbSQdjs4b2KruswTA1CtJq8fngWSdACkIZn0scwQ== + "@elastic/datemath@^5.0.3": version "5.0.3" resolved "https://registry.yarnpkg.com/@elastic/datemath/-/datemath-5.0.3.tgz#7baccdab672b9a3ecb7fe8387580670936b58573"