Privatus Analytics for Elixir and Phoenix

Copy Markdown View Source

Adds the cookieless Privatus Analytics tracking script to your Phoenix or Plug app. Use the function component in your layout, or add a Plug that puts the script in the <head> for you.

Privatus Analytics counts visits, sources, pages and conversions without cookies, local storage or fingerprinting, so analytics does not need a consent banner by itself. Check with your own counsel for your country.

The package only prints HTML: the browser script and a few HTML attributes. It never sends events from your server, sets no cookies, and never reads IP addresses or browser details. You need a Privatus Analytics account to see reports, and there is a free plan. Sign up at https://privatusanalytics.com.

Requirements

  • Elixir 1.15 or later.
  • Phoenix LiveView 1.0 or later for the function component (optional).
  • Plug 1.14 or later for the Plug (optional).
  • A Privatus Analytics site id (it starts with pa_). You find it under Site settings, Tracking.

Install

Add the package to your mix.exs:

def deps do
  [
    {:privatus_analytics, "~> 1.0"}
  ]
end

Then run mix deps.get. For the full setup guide and every tracker option, see the Privatus Analytics docs.

Set your site id with an environment variable:

PRIVATUS_SITE_ID=pa_YOURSITEID

or in your config (config/runtime.exs is a good place):

config :privatus_analytics, site_id: "pa_YOURSITEID"

The environment variable is read when the page renders, so it works in a release too.

Add the tag

Put the component in the <head> of your root layout, for example lib/my_app_web/components/layouts/root.html.heex:

<head>
  <title>My app</title>
  <PrivatusAnalytics.Components.tag />
</head>

In production it prints:

<script defer src="https://privatusstats.com/js/pa.js" data-site="pa_YOURSITEID"></script>

The tag prints nothing when there is no site id, and nothing outside production (see below).

Not using HEEx (Phoenix's HTML templates)? In an EEx template, or anywhere else, use the plain function. tag/1 returns safe HTML, and tag_string/1 returns a plain string:

<%= PrivatusAnalytics.tag() %>

Content Security Policy nonce

A Content Security Policy (CSP) is a header that tells the browser which scripts may run. If yours uses a nonce (a random value per request), pass it in:

<PrivatusAnalytics.Components.tag nonce={assigns[:csp_nonce]} />

You can also pass conn={@conn}. The tag then reads the nonce from conn.assigns.csp_nonce. Use config :privatus_analytics, nonce_assign: :my_nonce if your assign has another name.

Or let the Plug add it

If you would rather not touch your layouts, add the Plug to your browser pipeline:

# lib/my_app_web/router.ex
pipeline :browser do
  # ...
  plug PrivatusAnalytics.Plug
end

It adds the tag just before </head> in uncompressed text/html responses with status 200. It skips Ajax requests, pages without a <head>, and pages that already have the tag, and it fixes the content-length header. It uses Plug.Conn.register_before_send/2, so it works with any Plug app. It reads the nonce from conn.assigns.csp_nonce. Plug options work like the tag options: plug PrivatusAnalytics.Plug, modules: ["engage", "auto"].

Responses with a content-encoding header are left alone. Phoenix and Cowboy compress after the Plug runs, so this is only a problem if you compress the body yourself.

Configuration

Every setting is optional except the site id.

# config/config.exs
config :privatus_analytics,
  env: config_env(),
  site_id: "pa_YOURSITEID",                 # or PRIVATUS_SITE_ID
  script_host: "https://privatusstats.com", # or PRIVATUS_SCRIPT_HOST
  modules: ~w(engage auto vitals clicks ask),
  excluded_paths: ~w(/admin/* /preview/*),
  domains: ~w(example.com www.example.com),
  spa: "auto",                              # auto, history, hash or off
  manual: false,                            # true turns off the automatic pageview
  enabled_environments: [:prod],
  extra_attributes: %{"data-mask" => "/orders/*->/orders/:id"}
SettingDefaultWhat it does
site_idPRIVATUS_SITE_IDThe data-site attribute. Nothing prints without it
script_hosthttps://privatusstats.comWhere /js/pa.js is served. Change it only if you host your own copy
modulestracker default (engage)The data-modules attribute. Setting it replaces the default, so keep engage for time on page and scroll depth. [] loads none
excluded_pathsnoneThe data-exclude attribute. Path globs the tracker never counts
domainsall hostsThe data-domains attribute. Count only these hostnames
spaautoThe data-spa attribute (single-page app mode)
manualfalseThe data-manual attribute
envsee belowThe current environment, for example config_env()
enabled_environments[:prod]Environments that get the tag
enablednot settrue or false forces the tag on or off in every environment
extra_attributes%{}Any other data-* attribute
propsnonePage properties, see Page properties
queue_stubfalsetrue prints the queue stub before the tag
nonce_assign:csp_nonceThe conn assign that holds your CSP nonce

Each tracker attribute is explained in the script attributes reference, and the modules in Modules.

You can also override a setting for one page:

<PrivatusAnalytics.Components.tag modules={["engage", "auto"]} excluded_paths={["/preview/*"]} />

Development and test

Development and test requests are not counted by default, so your own clicking around never reaches your reports.

The package decides the environment like this:

  1. The env setting, if you set it. We recommend config :privatus_analytics, env: config_env() in config/config.exs.
  2. Otherwise Mix.env(), while Mix is running (mix phx.server, mix test).
  3. Otherwise :prod. Mix is not part of a release, so a release counts as production. If you build a staging release, set env (step 1) or enabled: false so staging is not counted.

If queue_stub is on, the tag still prints the small queue stub (and nothing else), so privatus.track calls in your own JavaScript keep working. They wait in a queue and are never sent.

To see the tag while you build, set config :privatus_analytics, enabled: true in config/dev.exs. The tracker also ignores localhost unless you pass data-allow-local or data-debug (see Debug mode):

config :privatus_analytics, extra_attributes: %{"data-debug" => "true"}

Leaving people out

Use data-exclude through excluded_paths for admin areas. To leave out signed-in staff, render the tag only for everyone else:

<PrivatusAnalytics.Components.tag :if={!(@current_user && @current_user.admin)} />

Product analytics: see which features people use

You can learn which features people use without any user ids. Tag the buttons and pages that matter, and Privatus counts them.

A good naming style is "Noun verb", like Report exported or Invite sent. Keep the name short and put the details in properties.

Events on buttons and forms

event_attrs/2 returns attributes for HEEx. The event is sent when the element is clicked:

<button {PrivatusAnalytics.event_attrs("Report exported", format: "csv", source: "toolbar")}>
  Export CSV
</button>

It prints:

<button data-privatus-event="Report exported" data-privatus-prop-format="csv" data-privatus-prop-source="toolbar">

On a <form>, the event is sent when the form is submitted:

<form action="/invites" method="post" {PrivatusAnalytics.event_attrs("Invite sent", role: "editor")}>

In EEx, use event_attrs_html/2 (safe HTML) or event_attrs_string/2 (a plain string):

<button <%= PrivatusAnalytics.event_attrs_html("Report exported", format: "csv") %>>Export</button>

The rules match the tracker. The name is trimmed and cut to 120 characters, and an empty name prints nothing. Property keys are lowercased, and anything other than a-z, 0-9 and _ becomes _. You can send up to 30 properties. Values become text (lists are joined with commas), nil is dropped, and values are cut to 500 characters. These attributes print even when the tag is off, so your HTML is the same in development and production.

Click maps

Give an element a stable name for click maps:

<a href="/signup" {PrivatusAnalytics.click_attrs("pricing.cta")}>Start free</a>

The name can use letters, digits and _ . : -, up to 100 characters. Any other name prints nothing, and a warning is logged outside production. EEx versions: click_attrs_html/1 and click_attrs_string/1.

Page properties

Page properties are sent with every pageview and event on the page. Use them for groups such as plan, role, app version, feature flags or account tier.

<PrivatusAnalytics.Components.tag props={%{plan: "pro", role: "admin"}} />

This prints a small script before the tag, and adds data-props="privatusPageProps" to the tag:

<script>window.privatusPageProps=function(){return {"plan":"pro","role":"admin"}}</script>

You can also set them in a controller or plug with put_props/2. Pass conn={@conn} to the component (the Plug reads the conn by itself):

conn |> PrivatusAnalytics.put_props(%{plan: account.plan, role: membership.role})

Or set a function in config. It gets the conn (or nil) on every request. Use a {module, function, args} tuple so it works in a release config:

config :privatus_analytics, props: {MyAppWeb.Analytics, :page_props, []}
defmodule MyAppWeb.Analytics do
  def page_props(%{assigns: %{current_account: account}}), do: %{plan: account.plan}
  def page_props(_conn), do: %{}
end

Config props come first, then put_props/2, then the props attribute. A later one wins when two set the same key. Numbers, booleans and lists of text keep their type. The JSON is escaped so it is safe inside a <script>.

Never put names, emails, user ids or anything else that points to one person in props. Privatus counts visitors without any id, so put groups (plan, role), not people. The server also removes emails and long ids, but do not rely on that.

Tracking from your own JavaScript

Call privatus.track from your JavaScript:

privatus.track("Feature used", { feature: "export" })

The tracker loads with defer, so it is not ready for scripts that run earlier. Add the queue stub, and calls made before the tracker loads are kept and sent once it arrives:

<PrivatusAnalytics.Components.tag queue_stub={true} />

The stub prints even when the tag is off (in development, without a site id, or with enabled: false). Only the stub prints then: no tracker and no page props. So your privatus.track calls never fail; they wait in the queue and are never sent.

Or print it alone with PrivatusAnalytics.queue_stub(nonce: @csp_nonce). See the JavaScript API docs.

LiveView

The root layout renders once, on the first page load. LiveView then moves between pages with the browser history, and the tracker's spa mode counts those page changes by itself. event_attrs and click_attrs work on any LiveView element, including buttons with phx-click.

Page properties in the root layout are set on that first load only. If they change while the LiveView runs, send them from a JavaScript hook with privatus.props, which adds properties to every later pageview and event:

<div id="privatus-props" phx-hook="PrivatusProps" hidden
     data-privatus-props={PrivatusAnalytics.props_json(%{plan: @plan})}></div>
// assets/js/app.js
const Hooks = {
  PrivatusProps: {
    mounted() { this.send() },
    updated() { this.send() },
    send() { window.privatus.props(JSON.parse(this.el.dataset.privatusProps)) }
  },
  TrackOnMount: {
    mounted() { window.privatus.track(this.el.dataset.event) }
  }
}
let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, params: { _csrf_token: csrfToken } })

Turn on the queue stub (queue_stub={true}) in the root layout. Then window.privatus is always there, so these calls are kept if the tracker has not loaded yet, and never fail in development.

Where to see it

  • Events lists every event and its properties.
  • Goals turn an event into a conversion.
  • Funnels show where people drop off between steps.
  • Click maps show which named elements get clicked.

Privacy

The script sets no cookies and stores nothing in the browser. See the privacy documentation for what is collected.

If you have a privacy policy, it should say which tools handle your visitors' data. You can paste this:

This website uses Privatus Analytics to count visits. It does not use cookies and does not follow you from one website to another. Your IP address is used for a moment to count visits and is never stored.

Change it to fit your site. The privacy policy generator makes a longer version with your site's name in it.

Development

The tests run in Docker so your local Elixir does not matter:

./run-docker.sh

It runs the ExUnit tests, mix format --check-formatted, Credo, the docs build (ExDoc) and mix hex.build.

Contributing

Bug reports and pull requests are welcome at https://github.com/Privatus-Analytics/elixir.

License

MIT. See LICENSE. Privatus Analytics is a product of Two Phase LLC, Wyoming, USA.