Plausible Web Analytics in Ember

Last reviewed on April 18, 2022

Recently I was exploring alternatives to Google Analytics and I came across Plausible, which markets itself as lightweight and open source web analytics with a focus on simplicity and privacy. I really enjoyed using it and found it so much simpler than Google Analytics.

With Plausible, you can either pay for the hosted service or you can access the source code and host it yourself.

Getting started is really simple. It really comes down to adding a script tag to an HTML file and you get metrics like top pages, total number of visitors per page, unique visitors, visit duration, a countries map, device breakdown of users, and plenty more.

Plausible also has a feature called Custom Events, which is useful if you want to track button clicks or form completions. To use this feature, you'll need to add a small internal script block to the page (see documentation) and then call a global plausible function when you want to track an event like a click. For example, if I wanted to track every time users click the Login button, I would add a click listener to my button and invoke the following:

const customProperties = {
  props: {
    url: "https://dtang.dev",
    route: "login",
  },
};

plausible("Login", customProperties);

customProperties is optional, but pretty useful. If you do use it, the props key is required.

While this may seem simple on the surface, I found that integrating this into an existing Ember application to be challenging for a few reasons.

First, we normally don't add event handlers to <LinkTo>. While we could add them, this would be annoying and messy having to add all this code to every link in an application.

Second, let's say we have a more-menu component in our design system like the following:

app/components/acme/more-menu.hbs
<div ...attributes>
  <Acme::DropdownTrigger @icon="elipsis" />
  <Acme::Dropdown @options={{@options}} />
</div>

We could invoke this component as follows:

<Acme::MoreMenu @options={{array "orange" "apple" "banana"}} />

We might have several of these menus in an application and I will likely want to customize what is passed to the global plausible function for each instance. In some cases, I may not want to even invoke plausible. I could add a few more props to Acme::MoreMenu, do some prop drilling, and conditionally invoke plausible, but that also seems messy.

With the challenges mentioned above, I wanted an approach that is simple and flexible. I decided to make a small abstraction where I could add a data-plausible attribute to any HTML element or component invocation and use event delegation to capture clicks and conditionally send custom events when a data-plausible attribute is present in any of the ancestor elements of the element that is clicked.

document.body.addEventListener("click", event => {
  const plausibleElement = findAncestorElementWithAttribute(
    event.target,
    "data-plausible"
  );

  if (!plausibleElement) {
    return;
  }

  const props = {
    ...plausibleElement.dataset,
  };

  delete props.plausible;

  plausible(plausibleElement.dataset.plausible, {
    props,
  });
});

function findAncestorElementWithAttribute(element, attribute) {
  if (!element) {
    return;
  }

  if (element.hasAttribute(attribute)) {
    return element;
  }

  return findAncestorElementWithAttribute(element.parentElement, attribute);
}

Now, I can easily add Plausible tracking to any element that is clicked by adding a data-plausible attribute. The above implementation will also capture any other data attributes and send those under props. Below are some examples of how I can now use this abstraction.

<Acme::MoreMenu
  data-plausible="More fruits"
  @options={{array "orange" "apple" "banana"}}
/>

<Acme::MoreMenu
  data-plausible="More veggies"
  @options={{array "kale" "spinach" "broccoli"}}
/>

<LinkTo @route="post" @model="5" data-plausible="Post" data-post-id="5">
  Post 5
</LinkTo>