Programmatic Data Funnel Creation with Plausible Analytics and Elixir

ANALYTICS ELIXIR

Today we will examine how to programmatically use insight gleaned from analytics to drive actions in Cassava with data funnels.

If you are familiar with Plausible Analytics, data funnels remain one of the top requested features.

So what to do when faced with a feature gap?

Roll your own data funnels, of course!

Gated Feature Releases

Cassava emits pseudonymized data events to Plausible Analytics to help us understand how the application is used.

I became intrigued by the thought of using this data to programmatically drive gated feature releases and multi-tenant migrations by assembling cohorts based on meaningful activity.

It turns out that this approach is not only possible but also elegant and delightful, which is welcome when you are writing code.

For this discussion, let us assume we wish to first release a new feature to customers who have recently registered and published an Experience in the last 30 days.

We will create a funnel comprised of four stages.

Top of Funnel

Onboard

A customer installs the Cassava Bot in their Slack workspace and grants the minimum sets of scopes needed upfront.

Demo

The customer publishes the onboarding demo to their workspace and experiences Cassava by playing the role of the sole audience member.

Author

The customer authors their first Experience with Disciplines, Principles, Assessments, and Attachments.

Publish

Lastly, the customer assembles an audience from their workspace members and publishes the Experience to them.

Goal Conversion

Now let us see how we can use the data analytics to assemble a matching cohort programmatically.

The Solution

Plausible Analytics provides a Stats API, which you can use to query the events submitted to the platform. Custom events are of particular interest as these can be used to create funnels.

Here we have the starting point for a function to build our cohort from a funnel.

def build_cohort(events, days, domain) do
  to =
    DateTime.utc_now()
    |> Timex.to_datetime("America/New_York")
    |> DateTime.to_date()

  from = to |> Timex.shift(days: -days)

  events
  |> Enum.map(&_get(&1, {from, to}, domain))
  |> process_funnel()
end

The function takes the four named events in the specified order.

  • onboard
  • demo
  • author
  • publish

Each event requires a call to the Stats endpoint to query for the data in question.

Events can only be filtered or broken down on a single custom property at this time. – Plausible API documentation.

The time period is specified using the range format for maximum flexibility. The timezone matches the value in Plausible’s site settings.

defp _get(event, {from, to}, domain) do
  url =
    "https://plausible.io/api/v1/stats/breakdown" <>
      "?site_id=#{domain}" <>
      "&period=custom" <>
      "&date=#{from},#{to}" <>
      "&metrics=events" <>
      "&property=event:props:id" <>
      "&filters=event:name==#{event}"

  url
  |> @http_client.get(headers(Application.get_env(:cassava, :plausible_api_key)))
  |> case do
    {:ok, %{"results" => results}} ->
      results |> Enum.map(& &1["id"])

    {:error, _} ->
      []
  end
end

defp headers(token),
  do: [
    Authorization: "Bearer " <> token,
    json: "application/json; charset=utf-8"
  ]

We use a thin client wrapper around HttpPoison to call the endpoint. This wrapper allows us to use Mox to unit test our integration, the details of which are not covered here.

The response includes the custom id property for each event grouped by the event’s frequency of occurrence.


{:ok,
 %{
   "results" => [
     %{"events" => 10, "id" => "A1045"}
     %{"events" => 3, "id" => "P49JB"}
     ...
   ]
 }}

We assemble this data into a funnel starting at the top and working our way down, retaining only those ids present in the lower stage.


defp process_funnel(stream),
  do:
    stream
    |> Enum.to_list()
    |> Enum.map(&Enum.uniq(&1))
    |> reduce_funnel_results()

defp reduce_funnel_results([]), do: {[], nil, nil, nil}

defp reduce_funnel_results(results) do
  top_of_funnel_results = List.first(results) || []
  top_of_funnel_count = length(top_of_funnel_results)

  {matching_stage_counts, final_stage_matches} =
    results
    |> Enum.map_reduce(top_of_funnel_results, fn x, acc ->
      matches = Enum.filter(x, &Enum.member?(acc, &1))
      {length(matches), matches}
    end)

  intermediate_stage_conversions =
    matching_stage_counts
    |> Enum.chunk_every(2, 1, :discard)
    |> Enum.map(fn [first, second] -> second / first end)

  overall_conversion = length(final_stage_matches) / top_of_funnel_count

  {final_stage_matches, overall_conversion, intermediate_stage_conversions, top_of_funnel_count}
end

The output is a tuple consisting of the ids in question along with additional conversion metrics.

Example data

{["P49JB", "RX938", ...], 0.2, [0.83, 0.65, 0.17], 100}

All that is left is for Cassava, or your application in this case, to dynamically release the feature to this identifiable subset of the customer base.

Beyond Funnels

You can create cohorts based solely on favorable activity with little additional effort. In this case, the signature of the function is:


@callback build_cohort(specs :: list()) :: list()

Each spec is a tuple consisting of event and timespan pairs, which allows each event to be queried over a distinct range.

The fetch operation for activity is just as with funnels. What varies is the assembly of the results, which is much more straightforward.


defp process_activity(stream),
  do:
    stream
    |> Enum.to_list()
    |> List.flatten()
    |> Enum.uniq()

Summary

There you have it.

We have endowed Cassava with the ability to transform our analytics into actionable data by dynamically creating funnels on the fly and using them to assemble and reach cohorts.

All that is required is a list of ordered events, a duration, and some Elixir pixy dust mixed with Plausible.

I do look forward to the day when funnels are natively supported in Plausible Analytics, along with an API to invoke them dynamically to replicate what I have outlined here.

But, it is always good to know that you can roll your own solution until then.

Your feedback is welcomed as always.

Michael


You no doubt have an opinion bubbling to the surface.
Let's go one step farther and add your voice to the conversation.
Your email is used to display your Gravatar and is never disclosed. As always, do review our moderation guidelines to keep the converstion friendly and respectful.