All notable changes to this project are documented here. This project follows Semantic Versioning.
0.6.1 - 2026-10-10
Fixed
- Chrome's speculative prefetch no longer shows up as a visit, and the
visitor it was fetched for is counted. A request marked
Sec-Purpose: prefetch…/Purpose: prefetchis stored flagged"prefetch"(visible under the reports' Bots switch, whatever Record bot traffic says), as its own visitor, so a prerender sent from a person's own browser neither starts nor joins their visit. A<link rel=prefetch>is still a page view. - A LiveView page served from Chrome's prefetch cache is counted. The
click makes no request, so the hook counts the first connect when the host
sends the
nav_delivery/prerendered/prerendering/doc_referrerconnect params (snippet inPhoenixKitWebAnalytics.LiveHookand the README's Installation step 5). It counts as JavaScript evidence for the "no JavaScript" bot judgement. - The client script holds its reports and session recording of a prerendered page until it is shown, so a page nobody opened sends nothing.
- The visit page says why a prefetch is flagged (en/et/ru).
- Staff-network expiry now follows every local staff sighting, while broadcasts keep their existing interval. Shortening the timeout no longer forgets a network with recent local activity. Concurrent sightings keep the newest time.
- LiveView navigation updates the open page's signed-in user, so the live list and the new page's leave use its current identity.
- HexDocs source links use the repository's
v-prefixed tags; documentation no longer links to hidden modules or a removed historical function.
Changed
AGENTS.mdrecords the prefetch rules.
0.6.0 - 2026-10-07
⚠️ Upgrade
- Run
mix phoenix_kit.update— migration V08 addstraffic_flags smallint NOT NULL DEFAULT 0to the events table (a catalogue change, no table rewrite, no index, no backfill). - Rows stored before the upgrade are not marked. Own traffic from before it stays in the reports, and in the rollups of the days already rolled up.
- Your own traffic is now left out by default — from every report, the rollups, "Right now", alerts and recordings — so the numbers drop by your team's visits from the day of the deploy. Each kind has a Leave out switch in Settings.
- Visits before a staff member shows up are not marked afterwards. A visit becomes a staff member's whole once they sign in during it, but earlier visits from the same address are not: no address is stored to match them.
- Staff networks behind NAT. A staff sign-in marks its network (an IPv4
address, an IPv6 /64) for 24 hours by default; behind a mobile network,
carrier-grade NAT or an office gateway, that leaves out everyone sharing
the address. Count them back in, or set
web_analytics_admin_network_hoursto0. - Recording chunks stored before a visit was marked stay; the visit is recorded no further from the moment it is.
- A report counting any own traffic in reads raw events (as bot traffic always has): the rollups hold only unmarked traffic, so days older than the raw-event retention show no data in that view.
- Known limit: a visit marked mid-way has its earlier hits marked too; if they fall on a day already rolled up for good (more than three hours after it ended), that day's rollup still counts them, so for that day a report leaving own traffic out (rollups) and one counting it in (raw) can differ by those hits.
Added
- Your own traffic, marked and left out (
PhoenixKitWebAnalytics.TrafficFlags,PhoenixKitWebAnalytics.InternalTraffic). Three marks, stored as bits on each event — never the address behind them:- Internal network — the address is in
config :phoenix_kit_web_analytics, internal_networks: [...](CIDR, IPv4/IPv6; app config, not a setting, so your addresses never reach the activity log). Settings shows how many are configured. - Site staff — the signed-in user holds a role in
web_analytics_internal_roles(Owner, Admin by default; an emptied field is stored as-, no one), by the roles they really hold. A hit naming only a user is judged by a 5-minute cache of their roles, filled off the hit's path. - Staff network — the address's network had a staff sign-in (from
core's session broadcast and the token's address) or a staff request (any
path, excluded ones included — read as the response is sent, so the
plug's place in the pipeline doesn't matter) within
web_analytics_admin_network_hours, judged by the hours set at the time; in memory only, shared between nodes when new. Private and loopback addresses are never taken.
- Internal network — the address is in
- A visit is marked whole: later hits inherit its marks, and a mark that first appears mid-visit is written back to its earlier hits, in the same transaction and under the same lock as the hit.
- Settings: a Leave out switch per mark (all on), the staff roles, the staff-network hours, and a note on NAT.
- Reports: Own traffic and Bots switches next to the period, kept in the URL; either reads raw events, with a note that days past the raw-event retention have no data there. The visit page names a visit's marks.
- "Right now" leaves out open pages and recent visits with a left-out mark.
Changed
- Rollups hold only unmarked, non-bot traffic (the watermark is not reset: days already rolled up keep what they had).
Reports.filter/1takesflagged:and carries the mask it leaves out (excluded_flags), so a cached report is keyed by it;recent_sessions/2takesbots:andexcluded_flags:(bots were hard-coded out).- Alerts and recordings skip marked traffic.
- The plug ignores requests with an
x-tidewave-diagnosticheader: Tidewave re-fetches each page after a live navigation in development, which counted every navigation twice.
Fixed
- The Visits list's Older / Newer links dropped the Own traffic and Bots switches (and the page filter), so paging quietly switched them off; they now carry the whole filter.
- The test helper
LiveCase.fake_scope/1built a scope whose roles core's own checks couldn't read (Scope.owner?/1was false for an "owner"); it now stores role names as core does.
0.5.0 - 2026-10-07
⚠️ Upgrade
- Add
:x_headersto the LiveView socket'sconnect_infofirst, then deploy — on both transports:connect_info: [:peer_data, :x_headers, :user_agent, session: @session_options]. Without it, a socket whose peer is a private or loopback address — a reverse proxy, a container network, a LAN,localhostin development — can no longer name its visitor, and the hook records nothing for it (no live navigation, interaction or "Right now" entry) instead of recording the peer as the visitor. Settings warns while that happens, and the "no JavaScript" bot check pauses while such skips are at least 5 % of a node's live visits in the last 24 hours. The skip count lives in memory, so after a restart the node that runs the check leaves alone every visit that started before it had been up for 30 minutes. X-Forwarded-Forfrom a private or loopback peer is now trusted with no configuration, and its LAST entry is taken — the one the proxy appended, as PhoenixKit core reads it for a login;X-Real-IPonly when there is no readableX-Forwarded-For. Before, the FIRST entry was read, and only withtrust_x_forwarded_for: true. The proxy must therefore set or appendX-Forwarded-Foritself: one that only setsX-Real-IP(nginx with justproxy_set_header X-Real-IP) passes a visitor's own header through, and the visitor can name any address. The same holds on an intranet where visitors have private addresses and reach the app directly.- Had
trust_x_forwarded_for: truebehind a CDN or a proxy with a public address (a CDN in front of the origin)? Add aRemoteIpplug (remote_ip) beforePhoenixKitWebAnalytics.Plug: a public peer's headers are ignored now, so without it every visitor is one of the CDN's addresses. Behind a chain (a CDN in front of a load balancer) the last entry is the CDN's, soRemoteIpis needed there as well. - LiveView behind a CDN and a load balancer:
RemoteIpfixes the plug but not the socket, which reads:x_headersby the rule above and sees the CDN's edge. Page load and live connection then hash to different visitors, and every LiveView visit is flagged a "no JavaScript" bot after 30 minutes. On such a site switch Spot bots by behaviour off (web_analytics_detect_bots). Withtrust_x_forwarded_for: trueand:x_headers, 0.4.0 read the first entry for both and didn't have this problem. - Expect more visitors and fewer bots from the day of the deploy. Behind a
proxy every visitor used to hash to the proxy's address: visitors sharing a
browser were merged into one, and the merged "visitor" was often flagged
rate(too many page views a minute). Visits recorded before the deploy are not recounted.
Fixed
- Behind a reverse proxy every visitor was the proxy. The plug, the beacon
and the LiveView hook read the client address by core's rule
(
PhoenixKit.Utils.IpAddress.client_address/1/client_address_from_socket/1, whose answer is taken for a public peer): behind a private or loopback peer, the lastX-Forwarded-Forentry across all its lines, thenX-Real-IP. The headers are read by the module itself there, because a core that can't parse a port (2.55 and earlier) passes over such anX-Forwarded-Forand answers withX-Real-IP, which a visitor can send. A port the proxy appended is dropped froma.b.c.d:portand[v6]:port(Caddy's{remote}); a bare IPv6 address is never cut (2001:db8::1:443is an address).::ffff:a.b.c.dis read as IPv4. A public peer is the visitor and its forwarded headers are ignored.
Changed
- The
connect_infosnippets (README, Settings,LiveHookdocs) list:x_headers. The README has a "Behind a reverse proxy" section, including the proxy-side fix for Caddy ({remote_host}, or noheader_upat all).
Deprecated
config :phoenix_kit_web_analytics, trust_x_forwarded_for:has no effect.trueis warned about once as the module starts;falseis ignored.
0.4.0 - 2026-10-07
⚠️ Upgrade
- Run
mix phoenix_kit.updatein the host — migration V07 adds two nullable columns tophoenix_kit_web_analytics_eventsand two partial indexes. On a large events table, build the indexesCONCURRENTLYfirst (see thePhoenixKitWebAnalytics.Migrationsdocs) and the migration skips them. - Rows written before the upgrade are not reclassified: earlier ad visits keep the channel they were recorded under ("direct", or "organic" when Google's Referer came along). An alert channel filter that leaves out "paid" will no longer alert on auto-tagged ad visits.
Added
- Ad-click identifiers are kept.
gclid,gbraid,wbraid(Google),msclkid(Microsoft Ads),fbclid(Meta),ttclid(TikTok) andli_fat_id(LinkedIn) are read off the landing URL alongsideutm_*; the first one present goes into the newclick_idcolumn and its parameter name intoclick_param(Google's conversion upload takesgclid,gbraidandwbraidin separate fields). Previously everything but the fiveutm_*keys was discarded, so the identifier needed to report a conversion back to the ad platform was lost on arrival. See the README's Privacy section. Web.BeaconPayload.utm_params/1(public) now returns ad-click identifiers alongsideutm_*; the name is kept.
Fixed
- An auto-tagged ad visit was recorded as "direct". Auto-tagging adds a
click identifier, not
utm_medium=cpc, and an ad click often arrives without a referrer, so the Acquisition report showed no paid traffic at all. A visit carrying an ad-only click identifier is now recorded under thepaidchannel, with the platform (named as for a referrer: "Google", "Bing", …) as its source unlessutm_sourcenames one.fbclidis the exception: Meta appends it to organic and Instagram clicks too, so it never overrides the referrer and only counts associalwhen nothing else classifies the visit. An internal page view stays internal when the identifier rides along on the link (Google'surl_passthrough). The Paid channel's help text on the Overview and Sources pages says so. - A campaign parameter that isn't valid UTF-8 (
?utm_source=%FF) made the insert fail and the hit was lost; the value is now dropped and the hit kept.
0.3.0 - 2026-10-01
⚠️ Upgrade
- Run
mix phoenix_kit.updatein the host — migrations V02–V06 add the session, rollup and recording columns/tables (each step is lock-light; the session-start backfill runs in batches from the retention pass, never in the migration). Requiresphoenix_kit ~> 2.38. - The visitor-hash secret moved to
web_analytics_hash_secret(see Fixed). A new one is generated on first use, so visitor IDs restart once. The oldweb_analytics_hash_saltrow is no longer read and can be deleted; earliersetting.changedactivity entries for it hold the old value in plaintext — purge them.
Added
- Server-side tracking: a LiveView
on_mounthook records live navigations, interactions (allow-listed params only) and exits straight from the socket;LivePresencepowers the new Right now page. - Sessions and a per-session timeline; Pages, Acquisition, Technology and Events reports rebuilt on shared dimensions.
- Optional client script (via
js_sources/0): outbound/download clicks, scroll depth, exits from non-LiveView pages. No cookies or storage. - Optional session recordings with a replay player on the visit page (coordinates and element positions only — never text or form values).
- Behavioural bot signals (automation flag, page-view speed, no JavaScript).
- Daily rollups (
daily_stats,daily_dims) read together with raw events, a single-flight report cache, and hourly retention that never prunes past the rollup watermark. - Website activity alerts (new visitors, tracked events, sign-ups) routed through core's notifications.
Adminmodule: every operator mutation is logged toPhoenixKit.Activity.- Estonian and Russian translations.
Fixed
- The visitor-hash salt was written in plaintext to the permanent activity
log (and again on every rotation), because core only withholds values of
keys whose name marks a secret. The key is now
web_analytics_hash_secret. - Rollup-backed reports (
overview,timeseries,sites) raised on a database error instead of degrading to empty results. - Monthly chart buckets vanished when the database session time zone was east of UTC.
- One-time tokens in core's route paths (
/users/reset-password/:token,/users/confirm/:token, magic-link, QR-login, confirm-email, access links) were stored, shown in the Pages report and put into alert text; they are now stored as:token. - Excluded paths and declared bots appeared under "Right now" and wrote leave events.
- "Tracked events" alerts were forgeable and uncapped; alert text could carry a
visitor-crafted
utm_sourcesentence; crawlers alerted with bot tracking on. Event alerts now have their own hourly cap, visitor-side text is cleaned, and bots never alert. - A page that only saw interactions listed as a zero-view top page until its day was rolled up.
- The rollup watermark was written once per rolled day (one permanent
setting.changedentry each); it is now written once per pass. - A NUL byte in a path, campaign parameter or event value lost the whole hit.
- The body-size limits on the beacon and recording endpoints only applied to
text/plain; a JSON body over the limit is now dropped too. - Settings errors named six fields in English only; 23 empty
enstrings filled.
0.2.3 - 2026-09-07
Fixed
- 0.2.0–0.2.2 fail to compile on Elixir 1.18.4 / OTP 28 with
cannot escape #Reference<...>inUserAgent: a compiled regex nested inside a{name, ~r//}list module attribute can't be injected intoparse/1's body on that combination.@browsers/@operating_systemsare now private functions instead of attributes; behaviour is unchanged. (#1, thanks @timujinne) - Pages report crashed once a period had 100+ ranked paths: the "showing the top N paths" note referenced
@page_limitinside the template, where@nameis always an assign, never the module attribute of the same name — now assigned inmount/3.
0.2.2 - 2026-09-07
Fixed
- Removed duplicate page headings across Web Analytics admin pages (Dashboard, Events, Pages, Acquisition, Technology, Settings) — each repeated the page title already shown in the top breadcrumb bar.
0.2.1 - 2026-08-11
Changed
- Dependency updates:
phoenix_kit2.2.0 and the transitive set it pulls (phoenix1.8.10,hackney4.7.3). No source changes in this package.
[0.2.0] - 2026-08-10
Changed
⚠️ Requires
phoenix_kit ~> 2.0. The core pin moved to~> 2.0, so this release no longer resolves against core 1.7.Core 2.0.0 squashes the migration chain into a single
V135baseline and makes V135 the chain's floor:mix ecto.migratenow refuses on a database below it rather than migrating. Checkmix phoenix_kit.statusbefore upgrading. A host below V135 must installphoenix_kit 1.7.236— the migration bridge, the last release carrying the full pre-squash chain — migrate until the reported version is at least V135, and only then move to 2.0.This package does not call migration internals, so the change is the pin itself.
Fixed
- This is the first release of this package to actually reach Hex. Its
package
files:list named aprivdirectory that does not exist, andmix hex.buildrefuses to build a package whose declared files are missing ("Missing files: priv") — so every publish attempt had failed before reaching the registry. The entry is dropped; add it back ifpriv/ever gains content. - The dashboard no longer fails to compile against core 2.0. Core 2.0 added
PhoenixKitWeb.Components.Core.Chart.bar_chart/1, which every LiveView imports viause PhoenixKitWeb, :live_view. That collided with this package's own same-aritybar_chart/1, making the unqualified call in the dashboard ambiguous and failing the build. The local component is renamedtraffic_chart/1; core's is a generic SVG chart keyed onid/data, while this one is bucket-aware and takesseries/metric/bucket, so they are not interchangeable and the local one is kept. Rendered output is unchanged. Callers using <code>PhoenixKitWebAnalytics.Web.Components.bar_chart/1</code> directly must rename the call.
0.1.0 - 2026-07-26
Initial release.
Collection
PhoenixKitWebAnalytics.Plug— server-side page view tracking. One line in the host's:browserpipeline; nothing is added to rendered pages. Writes happen in a supervised task after the response is sent, with amax_childrencap that drops rather than queues under load.PhoenixKitWebAnalytics.LiveHook— anon_mounthook that counts LiveViewpush_patch/push_navigatenavigation. Stays inert unless the endpoint socket exposes:peer_dataand:user_agent, rather than recording hits under a mismatched visitor hash.- Cookieless visitor identification: a daily-rotating salted SHA-256 of IP + User-Agent, truncated to 32 hex characters. No IP is stored.
- Server-side session stitching on an inactivity window (30 minutes by default) — no session cookie.
- Built-in User-Agent classification (browser, OS, device class, bot detection) and referrer classification into channels, with no external dependency or IP database.
- Campaign parameters (
utm_*) are extracted into their own columns; the rest of the query string is never stored. - Skips non-
GETrequests, non-2xx and non-HTML responses, excluded paths,DNT/Sec-GPCopt-outs, and bots. - Optional public collection endpoints, off by default: a ~300-byte inline
beacon snippet for browser-side custom events and a 1×1 pixel for
CDN-cached pages.
PhoenixKitWebAnalytics.Web.BeaconPayloadenforces the trust boundary — a payload controls content, never identity or origin. PhoenixKitWebAnalytics.track_event/2for server-side custom events.PhoenixKitWebAnalytics.Geobehaviour for optional country resolution, plus automatic use of CDN country headers (Cloudflare, Vercel, Fastly) when present.
Reports
PhoenixKitWebAnalytics.Reports— overview totals with period-over-period comparison, trend series (hour / day / month buckets), top pages, slowest pages by response time, referrers, channels, UTM campaigns and sources, browsers, operating systems, devices, countries, languages, custom events, a recent-hits feed, and live visitor count.- Six admin pages: Overview, Pages, Acquisition, Technology, Events, Settings. Charts are CSS-only — no charting library ships with this package.
- Period and site filters live in the URL, so a filtered report can be bookmarked and shared.
Storage
- Module-owned versioned migrations (
PhoenixKitWebAnalytics.Migrations), applied bymix phoenix_kit.update, withCOMMENT ON TABLEversion tracking and full--prefix(named-schema) support. phoenix_kit_web_analytics_events(append-only hits) andphoenix_kit_web_analytics_daily_stats(per-day, per-site rollups), both with UUIDv7 primary keys.PhoenixKitWebAnalytics.Retention— hourly rollup of completed days and batched pruning of raw events past the retention window (365 days by default). Pruning never runs ahead of the rollup that preserves the trend line;daily_timeseries/1falls back to rollups for pruned days.