by Doug

13/06/2019

Uncategorised

Consistent Optimize treatments – Cross device for logged in users

I offer this post as a proof of concept to solve for a specific use case following some internal work at ConversionWorks and a coincidental, somewhat related post from Industry Legend Simo Ahava

Read Simo’s article and you’ll see an elegant solution to first party cookie behaviour restrictions imposed by ITP.  The solution described here is a little different. When using Optimize (or any test platform for that matter), a user will be decorated with a cookie (gaexp) when exposed to a test treatment. It’s possible the user can revisit the site from another device and be exposed to a different treatment.  This is not cool.  We’re of the mind that regardless of the device used, the user experience needs to be consistent.  This matters when testing. This solution is applicable to logged in users as we can use the userId cookie as a key to match cookies in a data store.

Where Simo cleverly uses the Set-Cookie HTTP header in a response from an app-engine endpoint to set the _ga cookie, the technique described here uses a Cloud Function to respond with the value of the gaexp cookie in order to have consistent test treatments applied for logged in users across devices.

Admittedly, this is a bit of an edge case but it has value as does the general technique. Say if you had some meta data stored client side for personalisation, a shopping cart maybe, a logged in user legitimately expect to see the same content and functionality regardless of the device, once logged in.  You can see how the general technique may be tuned to fit your requirements.  Anyway, enough ramble, time to get technical!

Summary

A custom JavaScript tag in Google Tag Manager (GTM) detects logged in users.  An HTTPS request is sent to a cloud function. The request payload contains the userID.  If a gaexp cookie is present, it is also sent in the payload.

In the event of no gaexp cookie being present and only the userID being sent, the cloud function will use the userID value to query Cloud DataStore and attempt to find a matching record.  If a record is found, the response will contain the gaexp cookie.

If the userID and gaexp cookie are sent in the payload, the cloud function will create a record in Cloud DataStore and respond with “ack” to acknowledge the request.

On receiving the response from the cloud function, the tag will create the cookie if it is returned.  With respect to Simo’s technique, the Set-Cookie HTTP header in a response is a better choice.

Google Tag Manager

The following tag fires on all pages when a userID is present.  This is most likely to be a first party cookie placed by the site on a successful log in.

<script>
  var xhttp = new XMLHttpRequest(),
    payload = "userId=" + {{userId}},
    myDate = new Date();

  myDate.setMonth(myDate.getMonth() + 12);

  if({{gaexp}}!==undefined){
    payload = payload + "&gaexp=" + {{gaexp}};
  }

  xhttp.open("POST", "{{Cloud Function Endpoint for Cookie Management}}", true);

  xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

  xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      gaExp = this.responseText;
      if(gaExp!="ack"){
        document.cookie = "_gaexp=" + gaExp + ";expires=" + myDate + ";domain=.siteDomain.co.uk;path=/";
      }
    }
  }

  xhttp.send(payload);
</script>

The script tag is required as the tag is a custom JavaScript tag.  The {{userId}} variable needs to be configured to extract the userID placed by the site.

If the gaexp cookie is found, it is appended to the payload variable.  Add/change this as required for other cookies.

The {{Cloud Function Endpoint for Cookie Management}} variable is the cloud function endpoint.  Change as required.

The onreadystatechange code block handles the response from the cloud function.  Add/change the cookie setting as required.

The payload is sent to the cloud function and the tag awaits the response.

Cloud Function

Basic Cloud Function concepts (usage and pricing) are introduced here: https://cloud.google.com/functions/

The cloud function is HTTP triggered.  Change the memory allocation as required for optimal performance. It is unlikely that massive resource is required so 1GB of allocation is a good starting point.  When you configure your Cloud Function, you’ll see the URL to use in the GTM tag here:

This version of the cloud function is written in Node.js version 8.  Change as required. Cloud functions support Go (1.11 at time of writing), Node.js 10 is in beta.  Python 3.7 is also supported:

package.json
{
  "name": "sample-http",
  "version": "0.0.1",
  "dependencies": {
    "@google-cloud/datastore": "3.0.x"
  }
}
Index.js

I’ll go through this line by line – full script at the end for easy copy and pasting.

const {Datastore} = require('@google-cloud/datastore');

In order to query and write to cloud datastore, the {Datastore} constant is required.

const datastore = new Datastore({
  projectId: '[YOUR PROJECT ID]'
});

Change the projectId value as required to use the correct GCP Id.

const kindName = 'user-testing';

Change the datastore kindName value to reflect the correct kindName.  Cloud DataStore configuration is covered in the next section.

exports.userTesting = (req, res) => {
  res.header('Content-Type','application/json');
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Content-Type');

This is boilerplate code for HTTP triggered cloud functions.

  let userId = req.query.userId || req.body.userId || null;
  let gaexp = req.query.gaexp || req.body.gaexp || null;

Extract the userId and cookie(s) from either the querystring or POST body.  It is likely the POST method will be used.

  const name = userId;
  const datastoreKey = datastore.key([kindName,name]);
  var dsgaexp = null;

Initialise variables for querying and writing records in datastore.

  datastore.get(datastoreKey, function(err,entity){
    if(entity != undefined){
     dsgaexp = entity.gaexpCookie
       res.status(200).send(dsgaexp);

This is the query.  If a cookie is found for the userId, dsgaexp takes the value of the cookie and is returned with a 200 response code.

    }else{
        if(gaexp!=null){
          datastore
              .save({
                  key: datastoreKey,
                  data: {gaexpCookie: gaexp}
              })

If no cookie is found but has been passed on the payload, write the record.

              .catch(err => {
                  console.error('ERROR:', err);
                  res.status(200).send(err);
                  return;
              });

This is boilerplate defensive code to handle errors.

          res.status(200).send(gaexp);
        }else{
          res.status(200).send("ack");
        }
    }
  });
};

Send the appropriate response back to the GTM tag.

Cloud DataStore

Basic Cloud DataStore concepts (usage and pricing) are introduced here: https://cloud.google.com/datastore/

I got a massive leg up from this article – super useful on Cloud Data Store setup and usage in Cloud Functions.

Records are known as entities in Cloud DataStore.  Define your entity using the Create Entity function like so:

It is important to use a custom name for the identifier – this is the userID.  Add properties to store each cookie as required. The Kind field is essentially the name of your database as used in the Cloud Function.  When the data lands, it looks like so (test data!):

There you go

I’m not saying this is THE BEST way to do this.  I’m sure it can be refactored and improved – I’d be happy to hear your suggestions of course.

In retrospect, there’s a few things I know I want change in this solution in time.  Set the cookie using the response header a la Ahava. Port it to Python.  Be respectful of existing cookie values if they’re present. Call the Cloud Function less often – be more efficient. Use it for different cookies.  Perhaps use it to rehydrate Local Storage.  Use a Cloud Run endpoint instead of Cloud Function (When it’s available in the EU). The principle is to leverage a serverless HTTP endpoint from a GTM tag to….do things. You don’t have to store all yo’ data shizzle in GA folks!

Here’s a promise – next week I’ll post another proof of concept article that tweaks ideas previously published by Simo Ahava and Mark Edmondson (truly, I stand on the shoulders of giants) for other data pipeline use cases…remind me if I forget.  Thx!  🙂

Full Cloud Function Listing

const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore({
  projectId: '[YOUR PROJECT ID HERE]'
});

const kindName = 'user-testing';

exports.userTesting = (req, res) => {
  res.header('Content-Type','application/json');
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  let userId = req.query.userId || req.body.userId || null;
  let gaexp = req.query.gaexp || req.body.gaexp || null;
  const name = userId;
  const datastoreKey = datastore.key([kindName,name]);
  var dsgaexp = null;

  datastore.get(datastoreKey, function(err,entity){
    if(entity != undefined){
      dsgaexp = entity.gaexpCookie
      res.status(200).send(dsgaexp);
    }else{
      if(gaexp!=null){
        datastore
          .save({
            key: datastoreKey,
            data: {gaexpCookie: gaexp}
          })

        .catch(err => {
          console.error('ERROR:', err);
          res.status(200).send(err);
          return;
        });
        res.status(200).send(gaexp);
      }else{
        res.status(200).send("ack");
      }
    }
  });
};

Comments

Leave a comment

Your email address will not be published.