From fd9a37480080ea824aba0bc7036a36c21d5d0302 Mon Sep 17 00:00:00 2001 From: Logan Besecker Date: Fri, 18 Sep 2026 21:45:29 -0700 Subject: [PATCH] Show what the browser tag never saw The tag has to load and run before it can report, so anything that never executes JavaScript is invisible to it. That was unmeasurable until server-side recording existed. Now that the Phoenix plug reports the same pageviews the tag does, the gap between them is a number, and this tab is it. A pageview reported by the server alone carries no viewport height and no heartbeat, because a server has no viewport and does not sit on a page. Both are required before calling one untagged -- a visitor who leaves inside a second is gone before the first heartbeat, and either signal alone would misread them. - Splits misses into automated and not. A crawler missing the tag is expected; a person missing it is a blocked script or an untagged page, and the tab says so plainly - Counts crawlers even with the crawler filter on, since hiding the largest thing the tag misses would defeat the report - Groups by path, never title: a title is one of the things only the tag can supply 619 tests passing, 11 of them new. Closes #3 --- Pages affected: - [Seriously Simple Analytics](https://seriouslysimpleanalytics.com/) -- the dashboard this tab joins. - [AI crawler analytics](https://seriouslysimpleanalytics.com/ai-crawler-analytics) -- the automated half of what the tag misses. - [Analytics MCP server](https://seriouslysimpleanalytics.com/analytics-mcp-server) -- the same numbers, queryable from an agent. - [MCP Harbor](https://ai.mcpharbor.com/) -- a site already feeding this with server-side data. Co-Authored-By: Claude Opus 5 --- lib/web_analytics/analytics.ex | 118 +++++++++++++++ lib/web_analytics_web/live/dashboard_live.ex | 10 +- .../live/dashboard_live.html.heex | 115 ++++++++++++++ test/web_analytics/tag_coverage_test.exs | 142 ++++++++++++++++++ .../live/dashboard_live_test.exs | 27 ++++ 5 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 test/web_analytics/tag_coverage_test.exs diff --git a/lib/web_analytics/analytics.ex b/lib/web_analytics/analytics.ex index 51344ab..4faed86 100644 --- a/lib/web_analytics/analytics.ex +++ b/lib/web_analytics/analytics.ex @@ -2166,6 +2166,124 @@ defmodule WebAnalytics.Analytics do ) end + # -- tag coverage -------------------------------------------------------- + # + # What the browser tag missed, which is only answerable now that a server-side + # plug reports the same pageviews the tag does. Where both reported one, they + # merge into a single row; where only the server did, the row is missing + # everything a browser has to supply. + # + # Two markers, either of which settles it. The tag sends `window.innerHeight` + # on every pageview it opens, and it heartbeats afterwards; the plug sends + # neither, because a server has no viewport and does not stay on a page. So a + # pageview with no viewport height *and* no tick is one no tag ever reported. + # + # Either signal on its own would be wrong in a case that really happens: a + # visitor who leaves inside a second is gone before the first heartbeat, and a + # browser reporting no viewport height is unusual but not impossible. + + # Crawlers are the subject here, not noise in front of it, so the usual + # exclusion is deliberately not applied. A report about what the tag missed + # that hid the largest thing it misses would be worse than no report. + defp coverage_scope(f) do + from(p in Pageview, + join: s in assoc(p, :session), + as: :session, + where: p.site_id == ^f.site_id, + where: p.entered_at >= ^f.from and p.entered_at < ^f.to + ) + |> filter_joined_anomalies(f) + |> filter_joined_dwell(f) + |> filter_joined_origins(f) + |> filter_joined_sessions(f) + |> filter_joined_project(f) + |> filter_joined_host(f) + |> filter_joined_user(f) + end + + @doc """ + How much of this site's traffic the browser tag actually saw. + + Splits what it missed into automated and everything else, because the two + mean different things. Crawlers missing the tag is expected and is the reason + server-side recording exists. People missing it is a finding: blocked + scripts, a failed asset, a page the tag was never added to. + """ + def tag_coverage(f) do + totals = + Repo.one( + from [p, session: s] in coverage_scope(f), + select: %{ + pageviews: count(p.id), + tagged: filter(count(p.id), not is_nil(p.viewport_h) or p.tick_count > 0), + untagged: filter(count(p.id), is_nil(p.viewport_h) and p.tick_count == 0), + untagged_crawler: + filter(count(p.id), is_nil(p.viewport_h) and p.tick_count == 0 and s.crawler), + untagged_human: + filter(count(p.id), is_nil(p.viewport_h) and p.tick_count == 0 and not s.crawler), + human_pageviews: filter(count(p.id), not s.crawler) + } + ) || %{} + + totals + |> Map.put(:coverage, rate(Map.get(totals, :tagged, 0), Map.get(totals, :pageviews, 0))) + |> Map.put( + :human_coverage, + rate( + Map.get(totals, :human_pageviews, 0) - Map.get(totals, :untagged_human, 0), + Map.get(totals, :human_pageviews, 0) + ) + ) + end + + @doc """ + The pages the tag never reported, most-missed first. + + Always grouped by path, never by title, because a title is one of the things + only the tag can supply — grouping these by title would return one unnamed + row holding everything. + """ + def untagged_pages(f, limit \\ 25) do + Repo.all( + from [p, session: s] in coverage_scope(f), + where: is_nil(p.viewport_h) and p.tick_count == 0, + group_by: p.path, + order_by: [desc: count(p.id)], + limit: ^limit, + select: %{ + name: p.path, + count: count(p.id), + crawler: filter(count(p.id), s.crawler), + human: filter(count(p.id), not s.crawler), + sessions: count(p.session_id, :distinct), + last_seen: max(p.entered_at) + } + ) + end + + @doc """ + What was reading the pages the tag never saw, by user agent. + + Answers the question the coverage number raises: if a tenth of this site is + invisible to the tag, who is that? + """ + def untagged_clients(f, limit \\ 15) do + Repo.all( + from [p, session: s] in coverage_scope(f), + where: is_nil(p.viewport_h) and p.tick_count == 0, + group_by: [s.crawler_name, s.crawler_kind, s.crawler], + order_by: [desc: count(p.id)], + limit: ^limit, + select: %{ + name: s.crawler_name, + kind: s.crawler_kind, + crawler: s.crawler, + count: count(p.id), + sessions: count(p.session_id, :distinct) + } + ) + end + # -- breakdowns ---------------------------------------------------------- @doc "Top values of a session dimension, e.g. `:browser` or `:referrer_host`." diff --git a/lib/web_analytics_web/live/dashboard_live.ex b/lib/web_analytics_web/live/dashboard_live.ex index 779d1cc..dcbde39 100644 --- a/lib/web_analytics_web/live/dashboard_live.ex +++ b/lib/web_analytics_web/live/dashboard_live.ex @@ -16,7 +16,7 @@ defmodule WebAnalyticsWeb.DashboardLive do alias WebAnalytics.Ingest.Crawler alias WebAnalytics.Sites - @tabs ~w(live overview users pages events metrics flow locations clicks forms sessions anomalies crawlers) + @tabs ~w(live overview users pages events metrics flow locations clicks forms sessions anomalies crawlers coverage) @click_groups ~w(name id class text selector tag) @location_levels ~w(country region county city) @flow_modes ~w(pages events) @@ -622,6 +622,14 @@ defmodule WebAnalyticsWeb.DashboardLive do } end + defp tab_data("coverage", filters, _assigns) do + %{ + tag_coverage: Analytics.tag_coverage(filters), + untagged_pages: Analytics.untagged_pages(filters), + untagged_clients: Analytics.untagged_clients(filters) + } + end + # Loaded by assign_live/1 on its own interval rather than here, so the five # second refresh does not run it too. defp tab_data("live", _filters, _assigns), do: %{} diff --git a/lib/web_analytics_web/live/dashboard_live.html.heex b/lib/web_analytics_web/live/dashboard_live.html.heex index 859e9a5..76c07df 100644 --- a/lib/web_analytics_web/live/dashboard_live.html.heex +++ b/lib/web_analytics_web/live/dashboard_live.html.heex @@ -2130,6 +2130,121 @@ + +
+ <% cov = @data[:tag_coverage] || %{} %> + +
+ What the browser tag missed. The tag has to load and run before it can report, so + anything that never runs JavaScript is invisible to it — crawlers and AI agents by + nature, and real visitors when a script is blocked or a page was never tagged. These + pages are here because the server-side plug recorded them anyway. +
+ +
+ <.stat label="Pageviews recorded" value={number(cov[:pageviews])} /> + <.stat + label="Seen by the tag" + value={percent(cov[:coverage])} + hint={"#{number(cov[:tagged])} of #{number(cov[:pageviews])}"} + tone={if (cov[:coverage] || 0) >= 90, do: "success", else: "neutral"} + /> + <.stat + label="Missed — automated" + value={number(cov[:untagged_crawler])} + hint="Expected. This is why the plug exists." + /> + <.stat + label="Missed — not automated" + value={number(cov[:untagged_human])} + hint={"#{percent(100 - (cov[:human_coverage] || 100))} of non-bot pageviews"} + tone={if (cov[:untagged_human] || 0) > 0, do: "warning", else: "success"} + /> +
+ +
0} + class="rounded-box border border-warning/40 bg-warning/10 px-4 py-3 text-sm" + > + {number(cov[:untagged_human])} pageviews + came from something that was not automated and still never ran the tag. That is usually + a blocked script, a failed asset, or a page the snippet was never added to — worth + checking, because those visits would be missing entirely without the plug. +
+ +
+ <.bar_list + title="Pages the tag never saw" + rows={@data[:untagged_pages] || []} + empty="The tag saw everything" + /> + + <.bar_list + title="What was reading them" + rows={ + Enum.map(@data[:untagged_clients] || [], fn row -> + %{name: row.name || "Not automated", count: row.count} + end) + } + empty="Nothing went unreported" + /> +
+ +
+
+ Untagged pages in full +
+ + + + + + + + + + + + + + + + + + + + + + + + +
PathPageviewsAutomatedNot automatedSessionsLast seen
{page.name}{number(page.count)}{number(page.crawler)} 0 && "text-warning font-medium" + ]}> + {number(page.human)} + {number(page.sessions)} + {page.last_seen && Calendar.strftime(page.last_seen, "%d %b %H:%M")} +
+ Nothing went unreported +
+
+ +
+ Not seeing anything here? + This tab only fills up once server-side recording is installed — + <.link + href="https://github.com/lbesecker195/Phoenix-Analytics" + class="link link-primary" + target="_blank" + rel="noopener" + > + the Phoenix plug + + reports the pages your tag cannot, on the same account and the same visits. +
+
+ <%!-- The instructions live on /getting-started now. An account with traffic scrolled past them on every visit, and an account with none had to scroll past every empty chart to reach the only thing it needed. --%> diff --git a/test/web_analytics/tag_coverage_test.exs b/test/web_analytics/tag_coverage_test.exs new file mode 100644 index 0000000..ab70164 --- /dev/null +++ b/test/web_analytics/tag_coverage_test.exs @@ -0,0 +1,142 @@ +defmodule WebAnalytics.TagCoverageTest do + @moduledoc """ + What the browser tag missed. + + Only answerable because a server-side plug reports the same pageviews the tag + does: where both reported one they merge into a single row, and where only the + server did, the row is missing everything a browser has to supply. + """ + + use WebAnalytics.DataCase, async: true + + import WebAnalytics.Fixtures + + alias WebAnalytics.Analytics + alias WebAnalytics.Ingest + + setup do + site = site_fixture() + + # Seen by the tag: a viewport, and heartbeats afterwards. + submit(site, [ + init_event(), + pageview_event(1, "/", %{"vh" => 900, "dh" => 4200}), + tick_event(1, %{"d" => 20_000, "sp" => 70}) + ]) + + # An agent, reported only by the plug: no viewport, no heartbeat, because + # nothing ran in a browser. + submit( + site, + [ + init_event(%{"ua" => "ClaudeBot/1.0 (+https://anthropic.com/claudebot)"}), + pageview_event(1, "/llms.txt", %{"title" => nil}), + pageview_event(2, "/docs", %{"title" => nil}) + ], + token: "agent-visit" + ) + + # A person whose JavaScript never ran. This is the interesting one: not a + # bot, and still invisible to the tag. + submit( + site, + [ + init_event(%{"ua" => "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/120 Safari/537"}), + pageview_event(1, "/pricing", %{"title" => nil}) + ], + token: "no-js-visit" + ) + + {:ok, site: site, filters: Analytics.filters(site.id, %{"range" => "24h"})} + end + + describe "tag_coverage/1" do + test "counts what the tag saw against everything recorded", %{filters: f} do + coverage = Analytics.tag_coverage(f) + + assert coverage.pageviews == 4 + assert coverage.tagged == 1 + assert coverage.untagged == 3 + assert coverage.coverage == 25.0 + end + + test "separates automated misses from the ones worth worrying about", %{filters: f} do + coverage = Analytics.tag_coverage(f) + + # A crawler missing the tag is expected and is the reason the plug exists. + assert coverage.untagged_crawler == 2 + + # A person missing it is a finding: a blocked script, or an untagged page. + assert coverage.untagged_human == 1 + end + + test "reports coverage of non-automated traffic separately", %{filters: f} do + coverage = Analytics.tag_coverage(f) + + # Two non-bot pageviews, one of which the tag saw. + assert coverage.human_pageviews == 2 + assert coverage.human_coverage == 50.0 + end + + test "counts crawlers even though every other report hides them", %{site: site} do + # The default filters exclude crawlers. A report about what the tag misses + # that hid the largest thing it misses would be worse than no report. + filters = Analytics.filters(site.id, %{"range" => "24h", "crawlers" => "exclude"}) + + assert Analytics.tag_coverage(filters).untagged_crawler == 2 + end + end + + describe "untagged_pages/2" do + test "lists the pages the tag never reported", %{filters: f} do + paths = Analytics.untagged_pages(f) |> Enum.map(& &1.name) + + assert "/llms.txt" in paths + assert "/docs" in paths + assert "/pricing" in paths + refute "/" in paths, "the tag reported the home page" + end + + test "splits each page by whether the reader was automated", %{filters: f} do + pages = Analytics.untagged_pages(f) + + assert %{crawler: 1, human: 0} = Enum.find(pages, &(&1.name == "/llms.txt")) + assert %{crawler: 0, human: 1} = Enum.find(pages, &(&1.name == "/pricing")) + end + end + + describe "untagged_clients/2" do + test "says what was reading the pages the tag missed", %{filters: f} do + clients = Analytics.untagged_clients(f) + + assert %{count: 2} = Enum.find(clients, &(&1.name == "ClaudeBot")) + assert %{count: 1, crawler: false} = Enum.find(clients, &is_nil(&1.name)) + end + end + + describe "a site the plug is not installed on" do + test "reports full coverage rather than an alarming zero", %{site: site} do + other = site_fixture() + + submit(other, [ + init_event(), + pageview_event(1, "/", %{"vh" => 900}), + tick_event(1, %{"d" => 5_000}) + ]) + + coverage = Analytics.tag_coverage(Analytics.filters(other.id, %{"range" => "24h"})) + + assert coverage.coverage == 100.0 + assert coverage.untagged == 0 + assert Analytics.untagged_pages(Analytics.filters(other.id, %{"range" => "24h"})) == [] + + # And the first site is untouched by the second. + assert Analytics.tag_coverage(Analytics.filters(site.id, %{"range" => "24h"})).untagged == 3 + end + end + + defp submit(site, events, opts \\ []) do + {:ok, _} = + Ingest.submit_sync(site, payload(site, events, opts), received_at: DateTime.utc_now()) + end +end diff --git a/test/web_analytics_web/live/dashboard_live_test.exs b/test/web_analytics_web/live/dashboard_live_test.exs index d407c5f..e7095d5 100644 --- a/test/web_analytics_web/live/dashboard_live_test.exs +++ b/test/web_analytics_web/live/dashboard_live_test.exs @@ -221,6 +221,33 @@ defmodule WebAnalyticsWeb.DashboardLiveTest do assert html =~ "/secret-corner" end + test "the coverage tab shows the pages the tag never reported", %{conn: conn} do + {:ok, _live, html} = live(conn, ~p"/dashboard?site=dash&range=30d&tab=coverage") + + # Both of these were recorded without a viewport or a heartbeat, which is + # what a page reported by the server alone looks like. + assert html =~ "/secret-corner" + assert html =~ "/pricing" + + assert html =~ "What the browser tag missed" + end + + test "the coverage tab calls out misses that were not automated", %{conn: conn} do + # A crawler missing the tag is expected. A person missing it is a finding, + # and the tab has to say so rather than lump the two together. + {:ok, _live, html} = live(conn, ~p"/dashboard?site=dash&range=30d&tab=coverage") + + assert html =~ "Missed — not automated" + assert html =~ "never ran the tag" + end + + test "coverage counts crawlers even with the crawler filter on", %{conn: conn} do + {:ok, _live, html} = + live(conn, ~p"/dashboard?site=dash&range=30d&tab=coverage&crawlers=exclude") + + assert html =~ "/secret-corner" + end + test "flow can be grouped by path or by title", %{conn: conn} do {:ok, live, html} = live(conn, ~p"/dashboard?site=dash&range=30d&tab=flow")