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"}
]
endThen 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
endIt 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"}| Setting | Default | What it does |
|---|---|---|
site_id | PRIVATUS_SITE_ID | The data-site attribute. Nothing prints without it |
script_host | https://privatusstats.com | Where /js/pa.js is served. Change it only if you host your own copy |
modules | tracker default (engage) | The data-modules attribute. Setting it replaces the default, so keep engage for time on page and scroll depth. [] loads none |
excluded_paths | none | The data-exclude attribute. Path globs the tracker never counts |
domains | all hosts | The data-domains attribute. Count only these hostnames |
spa | auto | The data-spa attribute (single-page app mode) |
manual | false | The data-manual attribute |
env | see below | The current environment, for example config_env() |
enabled_environments | [:prod] | Environments that get the tag |
enabled | not set | true or false forces the tag on or off in every environment |
extra_attributes | %{} | Any other data-* attribute |
props | none | Page properties, see Page properties |
queue_stub | false | true prints the queue stub before the tag |
nonce_assign | :csp_nonce | The 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:
- The
envsetting, if you set it. We recommendconfig :privatus_analytics, env: config_env()inconfig/config.exs. - Otherwise
Mix.env(), while Mix is running (mix phx.server,mix test). - Otherwise
:prod. Mix is not part of a release, so a release counts as production. If you build a staging release, setenv(step 1) orenabled: falseso 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: %{}
endConfig 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.