Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(insights): POC click analytics integration #3547

Closed
wants to merge 4 commits into from
Closed

Conversation

tkrugg
Copy link
Contributor

@tkrugg tkrugg commented Feb 20, 2019

This POC demonstrating a possible integration between our clickAnalytics and InstantSearchJS.

How we currently do it: https://www.algolia.com/doc/guides/building-search-ui/going-further/plug-analytics/js/?language=js#using-algolia-insights-with-instantsearchjs

Test this out

  1. Go on https://deploy-preview-3547--instantsearchjs.netlify.com/stories/?selectedKind=ClickAnalytics&selectedStory=with%20function%20template&full=0&addons=1&stories=1&panelRight=0&addonPanel=storybook%2Factions%2Factions-panel
  2. open your browser console
  3. click on add to cart

How it is used

For instant search user, sending click events (or other events types) can be done with the following way

import * as analytics from "../src/helpers/analytics";

// initialize the search-insights clients
aa("init", {
  appId: "APPLICATION_ID",
  apiKey: "SEARCH_API_KEY",
  userHasOptedOut: false
});
aa("setUserToken", "xxx");

const search = instantsearch({
  indexName: "instant_search",
  /* ... */
  insightsClient: aa
});

// add the hits widget
search.addWidgets([
  instantsearch.widgets.configure({
    clickAnalytics: true
  }),
  instantsearch.widgets.hits({
    container,
    templates: {
      item: item => `
        <h3> ${item.name} </h3>
        <button ${analytics.clickedObjectIDsAfterSearch(item.objectID, {
          eventName: "Add to basket"
        })}>
           Add to basket
        </button>
      `
    }
  })
]);

This is going to call the clickedObjectIDsAfterSearch in the following way:

aa("clickedObjectIDsAfterSearch", {
  eventName: "Add to basket", // user provided
  index: "instant_search", // infered from `results`,
  queryID: "***", // infered from `results`,
  objectIDs: ["1"], // infered from `hit`,
  positions: [1] // computed and infered from `hit` and `results`,
});

With Hogan

<button
  {{#helpers.clickedObjectIDsAfterSearch}}{
    eventName: 'Add to cart'
  }{{/helpers.clickedObjectIDsAfterSearch}}>
  Add to basket
</button>

How it works

There are three parts:

  • connectHits is using createAnalyticsHelpers():
    • exposes analytics object with methods that call the insights client directly (useful for other flavours)
    • inject in every hit hit.__analytics = { index: "instant_search", queryID: "xxx", objectIDs: ["1"], positions: [10] }
  • helpers analytics.clickedObjectIDsAfterSearch are helpers generates data attributes
  • the WithClickAnalyticsWrapper wraps the Hits component and sets a click listener that reads data attribute and calls connector analytics methods

Possible improvements and unresolved questions

  • Conversion events are often sent outside the contexte of instantsearch. Can we help in that situation?
    We expose the queryID so the user can extract the queryID in the following way:
  <a href="https://mywebsite.com/?queryID=${item.__analytics.queryID}"> Add to basket </a>
  <a href="https://mywebsite.com/?queryID={{__analytics.queryID}}"> Add to basket </a>
  • This POC is intended to work on InfiniteHits as well.

@tkrugg tkrugg requested a review from a team February 20, 2019 17:02
@algobot
Copy link
Contributor

algobot commented Feb 20, 2019

Deploy preview for instantsearchjs ready!

Built with commit 0094cce

https://deploy-preview-3547--instantsearchjs.netlify.com

};
return `
data-analytics-event="clickedObjectIDsAfterSearch"
data-analytics-payload='${JSON.stringify(payload)}'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid issue with quotes or whatever we can encode/decode the value in base64.

__index: index,
__queryID: queryID,
analytics: {
clickedObjectIDsAfterSearch: eventName => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We expose the analytics attribute on the hit why not follow the same convention than we have with the highlight helper? It's an external function that takes the required parameters as input. We could use an external function that takes eventName, results, hits to create the payload and/or another function to create the attributes. Which means that we can re-use it to expose the Hogan version.

Copy link
Contributor Author

@tkrugg tkrugg Feb 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand your point well, this would require to pass results to the Template component inside data so that we can use it in the helpers, which also means exposing the results in every hit. Is this what you had in mind?

Copy link
Member

@francoischalifour francoischalifour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of assumptions in the code because we want to support multiple data types (string and objects). I think this should be stricter. It makes it confusing for the users and it makes the code hard to follow.

I don't find the way the higher-order component is made intuitive in the widget. We could abstract this logic by creating a proxy hit component that uses HitsWithAnalytics if the analytics prop is passed, the basic Hits component otherwise.

There's more things I've noticed but I'll keep it short in the first place :)

Do you plan on adding tests?

src/helpers/analytics.js Outdated Show resolved Hide resolved
@@ -76,6 +78,16 @@ export default function connectHits(renderFn, unmountFn) {
results.hits = escapeHits(results.hits);
}

// should we expose this only if instantSearchInstance contains clickAnalytics: true?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I would say so.

// we could decide to have more specific helpers to make the code lighter for func templates
// clickedObjectIDsAfterSearch(payload)
// etc
export function clickedObjectIDsAfterSearch(objectID, payload) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't objectID be part of the payload?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the helper layer, we only pass the data we want to override.
the exact payload is build by the connector layer so that it can benefit all the flavours.

serializedPayload = JSON.stringify(payload);
} catch (e) {
throw new Error(
`The analytics helper expects payload to be either a string or a JSON object`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we accept a single type? (I'd go for string)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered in your next comment 😄 #3547 (comment)

payload = JSON.parse(serializedPayload);
} catch (e) {
// assume it's the eventName
payload = { eventName: serializedPayload };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this assumption?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to allow the shorthand

<button ${analytics.clickedObjectIDsAfterSearch(item.objectID, 'Add to basket')}>
    Add to basket
</button>
<!-- vs -->
<button ${analytics.clickedObjectIDsAfterSearch(item.objectID, {eventName: 'Add to basket'})}>
    Add to basket
</button>

but it's debatable and I'm not holding on it

@@ -1,13 +1,15 @@
import reduce from 'lodash/reduce';
import forEach from 'lodash/forEach';
import find from 'lodash/find';
import findIndex from 'lodash/findIndex';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a new dependency – I think we can avoid it.

Copy link
Contributor Author

@tkrugg tkrugg Feb 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right but I think it has no extra cost here because we are already using find it depends on findIndex https://github.com/lodash/lodash/blob/4.17.11-npm/find.js#L2

@@ -496,6 +498,58 @@ function createDocumentationMessageGenerator(...widgets) {
};
}

function createAnalyticsHelpers(instantSearchInstance, results) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be moved to its own file.

};

const hasClickAnalytics =
instantSearchInstance.searchParameters.clickAnalytics;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could either go explicit with:

  const hasClickAnalytics =
    Boolean(instantSearchInstance.searchParameters.clickAnalytics);

Or more strict (needs to be aligned in the codebase):

  const hasClickAnalytics =
   instantSearchInstance.searchParameters.clickAnalytics === true;

import { withHits } from '../.storybook/decorators';
import * as analyticsHelpers from '../src/helpers/analytics';

const insightsClientMock = (eventName, payload) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use the action from Storybook (see Analytics story).

tkrugg and others added 2 commits February 27, 2019 16:21
Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>
@tkrugg
Copy link
Contributor Author

tkrugg commented Feb 27, 2019

@francoischalifour

There's a lot of assumptions in the code because we want to support multiple data types (string and objects). I think this should be stricter. It makes it confusing for the users and it makes the code hard to follow.

As I answered to your comment:
I want to allow the shorthand

<button ${analytics.clickedObjectIDsAfterSearch(item.objectID, 'Add to basket')}>
    Add to basket
</button>
<!-- vs -->
<button ${analytics.clickedObjectIDsAfterSearch(item.objectID, {eventName: 'Add to basket'})}>
    Add to basket
</button>

but it's debatable and I'm not holding on it

I don't find the way the higher-order component is made intuitive in the widget. We could abstract this logic by creating a proxy hit component that uses HitsWithAnalytics if the analytics prop is passed, the basic Hits component otherwise.

The HOC component here acts like the "proxy", do I'm not sure what you mean.
I wanted to keep this PR short but this same HOC will be used to add analytics on infinite-hits

const HitsWithAnalytics = withClickAnalytics(Hits)
const InfiniteHitsWithAnalytics = withClickAnalytics(InfiniteHits)

Do you plan on adding tests

Of course! also rewriting the new code in TS as much as possible.

@tkrugg
Copy link
Contributor Author

tkrugg commented Mar 11, 2019

closing this. Replaced by #3598

@tkrugg tkrugg closed this Mar 11, 2019
@Haroenv Haroenv deleted the clickAnalytics branch October 26, 2021 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants