Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ separately. No new key, no new setup.
Pageviews, engaged time, scroll depth, clicks, outbound clicks, forms and
page-to-page flow — captured automatically, nothing to tag.

## MCP server for AI agents

SeriouslySimpleAnalytics also ships as a remote
[MCP server](https://SeriouslySimpleAnalytics.com/analytics-mcp-server)
(Streamable HTTP at `/mcp`), so Claude, Cursor, VS Code and any other
MCP-speaking assistant can create an account, track events, and read
analytics reports without leaving the chat. 27 tools cover the whole
surface: `create_analytics_account`, `track_event`, `get_integration_guide`,
traffic and page reports (`get_analytics_overview`, `get_traffic_timeseries`,
`get_top_pages`, `get_traffic_sources`, `get_page_flow`), custom events and
metrics (`get_events`, `get_metrics` — revenue, tokens, sats, whatever you
count), per-user analytics (`list_users`, `get_user_activity`,
`get_live_visitors`), and AI crawler traffic (`get_ai_crawler_traffic` —
GPTBot, ClaudeBot, PerplexityBot, named and held out of your human numbers).

Point your MCP client at:

```
https://SeriouslySimpleAnalytics.com/mcp
```

Same free account, same data as the dashboard and the ping API above — the
MCP server is just another way in. Tracking needs no key; reading reports
needs the account's API key, created on
[Getting started](https://SeriouslySimpleAnalytics.com/getting-started).

## What you'll see

| | |
Expand Down
118 changes: 118 additions & 0 deletions lib/web_analytics/analytics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
Expand Down
117 changes: 117 additions & 0 deletions lib/web_analytics/api_keys.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
defmodule WebAnalytics.ApiKeys do
@moduledoc """
Read access to an account's reports, for programs.

The account ID is public — it sits in the page source of every tracked site —
so it can only ever be allowed to write. Reading a report back needs a
secret, and this is it: an MCP client sends one as a bearer token.

Keys are random, shown once, and stored only as a SHA-256 hash. A hash rather
than bcrypt because the key is 256 random bits, not a password: nothing about
it can be guessed, so a slow hash would only slow down every request that
presents one.
"""
import Ecto.Query

alias WebAnalytics.ApiKeys.ApiKey
alias WebAnalytics.Repo
alias WebAnalytics.Sites.Site

@prefix "ssa_"
@max_active 20
# Writing last_used_at on every request would turn each read into a write.
# Once a minute is plenty to answer "is this key still in use?".
@touch_after_seconds 60

@doc "The literal every key starts with, so a leaked one is recognisable."
def prefix, do: @prefix

@doc """
Mints a key for `site`. Returns `{:ok, token, api_key}` — the token is the
only copy there will ever be.
"""
def create(%Site{} = site, name \\ nil) do
if count_active(site) >= @max_active do
{:error, :too_many}
else
token = @prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)

%ApiKey{}
|> Ecto.Changeset.change(%{
site_id: site.id,
name: name |> to_string() |> String.trim() |> String.slice(0, 80) |> default_name(),
prefix: String.slice(token, 0, 12),
token_hash: hash(token)
})
|> Repo.insert()
|> case do
{:ok, key} -> {:ok, token, key}
{:error, changeset} -> {:error, changeset}
end
end
end

defp default_name(""), do: "API key"
defp default_name(name), do: name

@doc """
The site a presented token unlocks, or `:error` for anything that is not a
live key — unknown, revoked or malformed alike, so a caller cannot tell which.
"""
def authenticate(@prefix <> _ = token) do
query =
from k in ApiKey,
join: s in assoc(k, :site),
where: k.token_hash == ^hash(token) and is_nil(k.revoked_at),
select: {k, s}

case Repo.one(query) do
nil ->
:error

{key, site} ->
touch(key)
{:ok, site}
end
end

def authenticate(_token), do: :error

@doc "Keys for a site, newest first, revoked ones included."
def list(%Site{id: site_id}) do
Repo.all(from k in ApiKey, where: k.site_id == ^site_id, order_by: [desc: k.id])
end

@doc "Revokes one of `site`'s keys. A key belonging to another site is not found."
def revoke(%Site{id: site_id}, id) do
case Repo.get_by(ApiKey, id: id, site_id: site_id) do
nil ->
{:error, :not_found}

%ApiKey{revoked_at: nil} = key ->
key |> Ecto.Changeset.change(revoked_at: DateTime.utc_now()) |> Repo.update()

key ->
{:ok, key}
end
end

defp count_active(%Site{id: site_id}) do
Repo.aggregate(
from(k in ApiKey, where: k.site_id == ^site_id and is_nil(k.revoked_at)),
:count
)
end

defp touch(%ApiKey{last_used_at: last} = key) do
now = DateTime.utc_now()

if is_nil(last) or DateTime.diff(now, last) >= @touch_after_seconds do
Repo.update_all(from(k in ApiKey, where: k.id == ^key.id), set: [last_used_at: now])
end

:ok
end

defp hash(token), do: :crypto.hash(:sha256, token)
end
16 changes: 16 additions & 0 deletions lib/web_analytics/api_keys/api_key.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule WebAnalytics.ApiKeys.ApiKey do
@moduledoc "A secret that lets a program read one account's reports."
use Ecto.Schema

schema "api_keys" do
belongs_to :site, WebAnalytics.Sites.Site

field :name, :string
field :prefix, :string
field :token_hash, :binary, redact: true
field :last_used_at, :utc_datetime_usec
field :revoked_at, :utc_datetime_usec

timestamps(type: :utc_datetime_usec)
end
end
Loading
Loading