diff --git a/README.md b/README.md
index c95d4b8..a36444d 100644
--- a/README.md
+++ b/README.md
@@ -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
| | |
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/api_keys.ex b/lib/web_analytics/api_keys.ex
new file mode 100644
index 0000000..d80c93e
--- /dev/null
+++ b/lib/web_analytics/api_keys.ex
@@ -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
diff --git a/lib/web_analytics/api_keys/api_key.ex b/lib/web_analytics/api_keys/api_key.ex
new file mode 100644
index 0000000..f2555ad
--- /dev/null
+++ b/lib/web_analytics/api_keys/api_key.ex
@@ -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
diff --git a/lib/web_analytics/ingest/ping.ex b/lib/web_analytics/ingest/ping.ex
new file mode 100644
index 0000000..c32e831
--- /dev/null
+++ b/lib/web_analytics/ingest/ping.ex
@@ -0,0 +1,299 @@
+defmodule WebAnalytics.Ingest.Ping do
+ @moduledoc """
+ Turns the one-URL event API's parameters into an ingest batch.
+
+ Shared by the HTTP endpoint (`GET /api/ping`) and the MCP server's
+ `track_event` tool, so an event means exactly the same thing whichever way it
+ arrived: the same aliases, the same reserved names, the same automatic
+ sessions. Two copies of these rules would drift, and an agent switching from
+ one to the other would see its reports change shape for no visible reason.
+ """
+
+ alias WebAnalytics.Geo
+ alias WebAnalytics.Geo.Countries
+ alias WebAnalytics.Ingest
+ alias WebAnalytics.Sites
+
+ # Names that mean "someone looked at something", which are recorded as real
+ # pageviews so they land in the pages and flow reports rather than only in the
+ # event list.
+ @pageview_events ~w(page_view pageview pv view screen screen_view)
+
+ @reserved ~w(uid id site u type channel project app event name sid session path page
+ title ref referrer visitor v format bot agent ai tz timezone
+ email contact user user_id userid
+ c city cc county s_p state province region n nation country)
+
+ # How long consecutive pings from the same caller are treated as one session
+ # when no session id is supplied. Thirty minutes is the convention analytics
+ # has used for inactivity windows for decades.
+ @session_window_seconds 1_800
+
+ @doc "Parameter names with a fixed meaning, which are never kept as attributes."
+ def reserved, do: @reserved
+
+ @doc """
+ Records one ping.
+
+ `params` uses the API's own names — string keys, exactly as they would arrive
+ in a query string or JSON body. Options: `:ip` (the caller's address, used
+ only to hash and mask), `:headers` (for the location fallback) and
+ `:received_at`.
+
+ Returns `{:ok, site}`, or `:unknown_account`. The HTTP endpoint deliberately
+ answers both the same way; the distinction is for callers that already prove
+ they own the account.
+ """
+ def submit(params, opts \\ []) when is_map(params) do
+ received_at = Keyword.get(opts, :received_at, DateTime.utc_now())
+ ip = Keyword.get(opts, :ip)
+ headers = Keyword.get(opts, :headers, [])
+
+ case Sites.fetch_site_by_key(account_id(params)) do
+ nil ->
+ :unknown_account
+
+ site ->
+ ip_hash = Ingest.hash_ip(ip, site)
+
+ Ingest.submit(site, payload(params, received_at, session_token(params, site, ip_hash)),
+ received_at: received_at,
+ user_id: user_id(params),
+ user_traits: user_traits(params),
+ ip_hash: ip_hash,
+ ip_masked: Ingest.mask_ip(ip),
+ location: location(params, headers, ip),
+ project: param(params, ~w(project app)),
+ channel: param(params, ~w(type channel)) || "ai",
+ agent_name: param(params, ~w(name agent ai)),
+ contact_email: param(params, ~w(email contact))
+ )
+
+ {:ok, site}
+ end
+ end
+
+ # -- user ----------------------------------------------------------------
+
+ # The caller's own ID for who this ping is about: an account, a customer, a
+ # mailbox. Accepted as a number as well as a string, because a JSON body
+ # carries ids as numbers far more often than not, and param/2 — built for
+ # query strings — would drop `"user": 42` without a word.
+ #
+ # `visitor` is the older name for the same idea and still counts, so a tool
+ # already sending it shows up by user without changing anything.
+ defp user_id(params) do
+ Enum.find_value(~w(user user_id userid visitor), fn key ->
+ case Map.get(params, key) do
+ value when is_integer(value) -> Integer.to_string(value)
+ value when is_binary(value) -> blank_to_nil(value)
+ _ -> nil
+ end
+ end)
+ end
+
+ # Every other identifier, by prefix: user_domain, user_address, user_plan.
+ # One convention rather than a fixed list, because what identifies a user is
+ # the caller's business — an email provider has an account, a domain and an
+ # address; a marketplace has a seller and a wallet.
+ #
+ # They stay on the event as ordinary attributes too. This copy is what lets
+ # the dashboard show them beside the user without reading every event.
+ defp user_traits(params) do
+ params
+ |> Enum.filter(fn {key, value} ->
+ is_binary(key) and String.starts_with?(key, "user_") and key not in @reserved and
+ (is_binary(value) or is_number(value) or is_boolean(value))
+ end)
+ |> Map.new(fn {key, value} ->
+ {String.replace_prefix(key, "user_", ""), to_string(value)}
+ end)
+ end
+
+ defp blank_to_nil(value) do
+ case String.trim(value) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ # -- location ------------------------------------------------------------
+
+ # A caller's own location parameters always win over anything this server
+ # could work out.
+ #
+ # That is not a preference, it is the only correct answer for this endpoint:
+ # a ping arrives from wherever the tool runs — a laptop, a container, a
+ # serverless region three countries away — so its source address says where
+ # the *software* is, not where its user is. Resolving the address would
+ # produce a confident, wrong answer. Falling back to it at all is only
+ # reasonable for the browser tracker, where the connection really is the
+ # visitor's.
+ defp location(params, headers, ip) do
+ supplied = %{
+ city: param(params, ~w(c city)),
+ county: param(params, ~w(cc county)),
+ region: param(params, ~w(s_p state province region)),
+ country: param(params, ~w(n nation country))
+ }
+
+ if Enum.any?(Map.values(supplied), &is_binary/1) do
+ code = Countries.code_for(supplied.country)
+
+ %{
+ Geo.empty()
+ | city: supplied.city,
+ county: supplied.county,
+ region: supplied.region,
+ country: Countries.name(code) || supplied.country,
+ country_code: code,
+ source: "client"
+ }
+ else
+ Ingest.locate(headers, ip)
+ end
+ end
+
+ # -- payload -------------------------------------------------------------
+
+ defp payload(params, received_at, token) do
+ # `name` is deliberately NOT an alias for `event` any more. It now identifies
+ # the AI tool doing the reporting, and one parameter cannot mean two things:
+ # a caller sending name=Claude would otherwise have silently renamed its
+ # event instead of identifying itself.
+ event_name = param(params, ~w(event)) || "ping"
+ path = param(params, ~w(path page))
+ now = DateTime.to_unix(received_at, :millisecond)
+
+ %{
+ "k" => account_id(params),
+ "s" => token,
+ # A user id is the best visitor identity a ping can have: it is what makes
+ # "visitors" count people rather than sessions for a tool that says who
+ # it is acting for.
+ "v" => param(params, ~w(visitor v)) || user_id(params),
+ "t" => now,
+ "e" => [init_event(params, now) | [body_event(event_name, path, params, now)]]
+ }
+ end
+
+ # The caller's user agent is deliberately *not* forwarded for classification.
+ # A ping is a tool reporting its own usage, and most such callers are a script
+ # or an HTTP library — classifying them by user agent would file every one as
+ # a crawler and quietly filter the owner's own telemetry out of their own
+ # reports. Only an explicit `bot=` marks a ping as automated.
+ defp init_event(params, now) do
+ %{
+ "n" => "init",
+ "t" => now,
+ "ref" => param(params, ~w(ref referrer)),
+ "bot" => param(params, ~w(bot agent)),
+ "tz" => param(params, ~w(tz timezone)),
+ "hb" => 10_000
+ }
+ end
+
+ # `event=page_view` with a path is a real pageview; everything else is a
+ # named event. That mapping is documented, so a caller gets pages and flow
+ # reporting by naming the event the obvious thing rather than by learning a
+ # second parameter.
+ defp body_event(event_name, path, params, now) do
+ if String.downcase(event_name) in @pageview_events and path do
+ # No `seq` and no `from`: the server continues the session's sequence and
+ # links this page to the one before it, which is what builds the flow
+ # graph without the caller having to track any of it.
+ %{
+ "n" => "pv",
+ "t" => now,
+ "path" => path,
+ "title" => param(params, ~w(title)),
+ "ref" => param(params, ~w(ref referrer))
+ }
+ else
+ # No `pv` either: the event attaches to whatever page the session is on.
+ %{
+ "n" => "event",
+ "t" => now,
+ "name" => event_name,
+ "text" => param(params, ~w(title)),
+ "data" => extras(params)
+ }
+ end
+ end
+
+ # Anything the caller invented, kept as attributes.
+ defp extras(params) do
+ params
+ |> Enum.reject(fn {key, value} -> key in @reserved or is_nil(value) end)
+ |> Enum.take(20)
+ |> Map.new(fn {key, value} -> {to_string(key), attribute_value(value)} end)
+ end
+
+ # A query string only ever carries strings, but a JSON body carries whatever
+ # the caller put in it. to_string/1 has no clause for a map or a list, so
+ # "meta": {"repo": "x"} raised — and the whole ping was lost with a 500, not
+ # just the field that could not be stored. An agent posting structured
+ # context would have been silently dropping every event it sent.
+ #
+ # Nested values are kept as their JSON rather than thrown away, capped so one
+ # large blob cannot bloat a row. They never become metrics — a key is a
+ # metric only when its values are plain numbers — which is correct: there is
+ # nothing to sum in an object.
+ defp attribute_value(value) when is_binary(value), do: value
+ defp attribute_value(value) when is_number(value) or is_boolean(value), do: to_string(value)
+
+ defp attribute_value(value) when is_map(value) or is_list(value) do
+ value |> Jason.encode!() |> String.slice(0, 1_000)
+ end
+
+ defp attribute_value(value), do: inspect(value)
+
+ # A session id from the caller is authoritative. Without one, pings are grouped
+ # by who and what they came from within a rolling half-hour, so that a tool
+ # that never passes `sid` still produces sessions — and therefore page flow —
+ # rather than a pile of one-event sessions that no report can connect.
+ #
+ # The window tumbles rather than sliding, so a long run can straddle a boundary
+ # and split in two. That is the price of deriving a session without a lookup on
+ # every ping; `sid` is there for callers that need exactness.
+ defp session_token(params, site, ip_hash) do
+ case param(params, ~w(sid session)) do
+ nil -> derived_token(params, site, ip_hash)
+ explicit -> explicit
+ end
+ end
+
+ # A user id comes first. A backend reporting for many users sends every ping
+ # from one address, and grouping by address would fold all of them into a
+ # single session — one "user" doing everything at once.
+ defp derived_token(params, site, ip_hash) do
+ project = param(params, ~w(project app)) || "-"
+ who = user_id(params) || param(params, ~w(visitor v)) || ip_hash || "anon"
+ window = div(System.system_time(:second), @session_window_seconds)
+
+ digest =
+ :sha256
+ |> :crypto.hash([site.key, "|", project, "|", who, "|", Integer.to_string(window)])
+ |> Base.url_encode64(padding: false)
+ |> binary_part(0, 22)
+
+ "auto-" <> digest
+ end
+
+ defp account_id(params), do: param(params, ~w(uid id site u))
+
+ defp param(params, keys) do
+ Enum.find_value(keys, fn key ->
+ case Map.get(params, key) do
+ value when is_binary(value) ->
+ case String.trim(value) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+
+ _ ->
+ nil
+ end
+ end)
+ end
+end
diff --git a/lib/web_analytics_web/account_provisioning.ex b/lib/web_analytics_web/account_provisioning.ex
new file mode 100644
index 0000000..d601c68
--- /dev/null
+++ b/lib/web_analytics_web/account_provisioning.ex
@@ -0,0 +1,146 @@
+defmodule WebAnalyticsWeb.AccountProvisioning do
+ @moduledoc """
+ Self-service account creation, for agents.
+
+ Shared by `POST /api/v1/accounts` and the MCP server's
+ `create_analytics_account` tool. An AI tool can call either, get an account
+ id back, and start reporting events in the same run. Requiring a human to
+ stop and fill in a signup form first is the thing most likely to end an
+ integration before it starts, so there is no form and no key exchange.
+
+ Two shapes, depending on whether a human is reachable:
+
+ * with `email` — the magic link is mailed to that address and never
+ returned, so the address still has to be controlled by whoever ends up
+ signing in;
+ * without `email` — the account is anonymous and the result carries a
+ one-time claim link, which is then the only way into it.
+ """
+ use WebAnalyticsWeb, :verified_routes
+
+ alias WebAnalytics.Accounts
+ alias WebAnalytics.RateLimiter
+ alias WebAnalytics.Sites
+
+ # Generous enough that a developer retrying by hand never notices, low enough
+ # that a script cannot fill the users table.
+ @limit 5
+ @window_ms 3_600_000
+
+ @doc """
+ Creates an account for `params` (`project` or `name`, and optionally `email`
+ or `contact`), rate limited per client address.
+
+ Returns `{:ok, body, site}`, or `{:error, reason, body}` where reason is
+ `:email_taken`, `:invalid` or `{:rate_limited, retry_after_seconds}`. The
+ bodies are what the HTTP endpoint returns, so both callers describe an
+ outcome in the same words.
+ """
+ def create(params, client_ip) do
+ case RateLimiter.hit({:account_create, client_ip}, @limit, @window_ms) do
+ :ok -> provision(params)
+ {:error, retry_after} -> {:error, {:rate_limited, retry_after}, rate_limited(retry_after)}
+ end
+ end
+
+ defp provision(params) do
+ email = normalize_email(params["email"] || params["contact"])
+
+ if email && Accounts.get_user_by_email(email) do
+ {:error, :email_taken,
+ %{
+ error: "email_taken",
+ message: "That email already has an account. Sign in to see its account id.",
+ login_url: url(~p"/users/log-in")
+ }}
+ else
+ do_provision(email, Sites.generate_key(), params)
+ end
+ end
+
+ defp do_provision(email, key, params) do
+ # A reserved TLD, so an anonymous account can satisfy the unique-email
+ # constraint without ever addressing mail at a real person.
+ login_email = email || "#{key}@unclaimed.invalid"
+ site_attrs = %{"key" => key, "name" => site_name(params)}
+
+ case Accounts.provision_account(%{email: login_email}, site_attrs) do
+ {:ok, user, site, token} ->
+ {:ok, created(user, site, token, email), site}
+
+ {:error, changeset} ->
+ {:error, :invalid,
+ %{error: "invalid", message: "Could not create an account.", details: errors(changeset)}}
+ end
+ end
+
+ defp created(user, site, token, email) do
+ body = %{
+ uid: site.key,
+ account_id: site.key,
+ project: site.name,
+ dashboard_url: url(~p"/dashboard"),
+ docs_url: url(~p"/llms.txt"),
+ ping_url: ping_url(site)
+ }
+
+ if email do
+ Accounts.deliver_login_instructions(user, &url(~p"/users/log-in/#{&1}"))
+
+ Map.merge(body, %{
+ claim: "emailed",
+ message: "Account created. A sign-in link was emailed to #{email}."
+ })
+ else
+ Map.merge(body, %{
+ claim: "link",
+ claim_url: url(~p"/users/log-in/#{token}"),
+ message:
+ "Account created. Give claim_url to a human to open the dashboard — " <>
+ "it is the only way in, so store it or set an email in settings."
+ })
+ end
+ end
+
+ defp ping_url(site) do
+ url(~p"/api/ping?#{[uid: site.key, type: "ai", project: site.name, event: "page_view"]}")
+ end
+
+ defp rate_limited(retry_after) do
+ %{
+ error: "rate_limited",
+ message: "Too many accounts created from this address. Try again later.",
+ retry_after: retry_after
+ }
+ end
+
+ defp site_name(params) do
+ case params["project"] || params["name"] do
+ value when is_binary(value) ->
+ case value |> String.trim() |> String.slice(0, 80) do
+ "" -> "My project"
+ trimmed -> trimmed
+ end
+
+ _ ->
+ "My project"
+ end
+ end
+
+ defp normalize_email(value) when is_binary(value) do
+ case value |> String.trim() |> String.downcase() do
+ "" -> nil
+ email -> email
+ end
+ end
+
+ defp normalize_email(_), do: nil
+
+ defp errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), "") |> to_string()
+ end)
+ end)
+ end
+end
diff --git a/lib/web_analytics_web/client_ip.ex b/lib/web_analytics_web/client_ip.ex
new file mode 100644
index 0000000..584a121
--- /dev/null
+++ b/lib/web_analytics_web/client_ip.ex
@@ -0,0 +1,28 @@
+defmodule WebAnalyticsWeb.ClientIP do
+ @moduledoc """
+ The address a request came from.
+
+ `x-forwarded-for` is client-controlled and only trusted when the deployment
+ says it sits behind a proxy that overwrites it (`SSA_TRUST_PROXY`). Behind
+ one, the socket address is the proxy's own — every visitor would share it —
+ so ignoring the header there is as wrong as trusting it anywhere else.
+
+ The address is never stored raw: callers salt and hash it, or mask it.
+ """
+ import Plug.Conn
+
+ @doc "The client address as a string, or nil if the connection has none."
+ def get(conn) do
+ if Application.get_env(:web_analytics, :trust_proxy_headers, false) do
+ case get_req_header(conn, "x-forwarded-for") do
+ [value | _] -> value |> String.split(",") |> List.first() |> String.trim()
+ [] -> remote_ip(conn)
+ end
+ else
+ remote_ip(conn)
+ end
+ end
+
+ defp remote_ip(%Plug.Conn{remote_ip: nil}), do: nil
+ defp remote_ip(%Plug.Conn{remote_ip: ip}), do: ip |> :inet.ntoa() |> to_string()
+end
diff --git a/lib/web_analytics_web/controllers/account_controller.ex b/lib/web_analytics_web/controllers/account_controller.ex
index d9f4f4f..799cc92 100644
--- a/lib/web_analytics_web/controllers/account_controller.ex
+++ b/lib/web_analytics_web/controllers/account_controller.ex
@@ -4,160 +4,32 @@ defmodule WebAnalyticsWeb.AccountController do
POST /api/v1/accounts
- An AI tool that reads `llms.txt` can call this, get an account id back, and
- start reporting events in the same run. Requiring a human to stop and fill in
- a signup form first is the thing most likely to end an integration before it
- starts, so there is no form, no key exchange and no auth header here.
-
- Two shapes, depending on whether a human is reachable:
-
- * with `email` — the magic link is mailed to that address and never returned
- in the response, so the address still has to be controlled by whoever ends
- up signing in;
- * without `email` — the account is anonymous and the response carries a
- one-time claim link, which is then the only way into it.
+ The rules live in `WebAnalyticsWeb.AccountProvisioning`, shared with the MCP
+ server; this is the HTTP shape around them.
"""
use WebAnalyticsWeb, :controller
- require Logger
-
- alias WebAnalytics.Accounts
- alias WebAnalytics.RateLimiter
- alias WebAnalytics.Sites
-
- # Generous enough that a developer retrying by hand never notices, low enough
- # that a script cannot fill the users table.
- @limit 5
- @window_ms 3_600_000
+ alias WebAnalyticsWeb.AccountProvisioning
+ alias WebAnalyticsWeb.ClientIP
def create(conn, params) do
- case RateLimiter.hit({:account_create, client_ip(conn)}, @limit, @window_ms) do
- :ok -> provision(conn, params)
- {:error, retry_after} -> rate_limited(conn, retry_after)
- end
- end
-
- def options(conn, _params), do: send_resp(conn, 204, "")
-
- defp provision(conn, params) do
- email = normalize_email(params["email"] || params["contact"])
- key = Sites.generate_key()
+ case AccountProvisioning.create(params, ClientIP.get(conn)) do
+ {:ok, body, _site} ->
+ conn |> put_status(:created) |> json(body)
- cond do
- email && Accounts.get_user_by_email(email) ->
- conn
- |> put_status(:conflict)
- |> json(%{
- error: "email_taken",
- message: "That email already has an account. Sign in to see its account id.",
- login_url: url(~p"/users/log-in")
- })
-
- true ->
- do_provision(conn, email, key, params)
- end
- end
-
- defp do_provision(conn, email, key, params) do
- # A reserved TLD, so an anonymous account can satisfy the unique-email
- # constraint without ever addressing mail at a real person.
- login_email = email || "#{key}@unclaimed.invalid"
- site_attrs = %{"key" => key, "name" => site_name(params)}
+ {:error, :email_taken, body} ->
+ conn |> put_status(:conflict) |> json(body)
- case Accounts.provision_account(%{email: login_email}, site_attrs) do
- {:ok, user, site, token} ->
- respond_created(conn, user, site, token, email)
+ {:error, :invalid, body} ->
+ conn |> put_status(:unprocessable_entity) |> json(body)
- {:error, changeset} ->
+ {:error, {:rate_limited, retry_after}, body} ->
conn
- |> put_status(:unprocessable_entity)
- |> json(%{
- error: "invalid",
- message: "Could not create an account.",
- details: errors(changeset)
- })
+ |> put_resp_header("retry-after", to_string(retry_after))
+ |> put_status(:too_many_requests)
+ |> json(body)
end
end
- defp respond_created(conn, user, site, token, email) do
- body = %{
- uid: site.key,
- account_id: site.key,
- project: site.name,
- dashboard_url: url(~p"/dashboard"),
- docs_url: url(~p"/llms.txt"),
- ping_url: ping_url(site)
- }
-
- body =
- if email do
- Accounts.deliver_login_instructions(user, &url(~p"/users/log-in/#{&1}"))
-
- Map.merge(body, %{
- claim: "emailed",
- message: "Account created. A sign-in link was emailed to #{email}."
- })
- else
- Map.merge(body, %{
- claim: "link",
- claim_url: url(~p"/users/log-in/#{token}"),
- message:
- "Account created. Give claim_url to a human to open the dashboard — " <>
- "it is the only way in, so store it or set an email in settings."
- })
- end
-
- conn
- |> put_status(:created)
- |> json(body)
- end
-
- defp ping_url(site) do
- url(~p"/api/ping?#{[uid: site.key, type: "ai", project: site.name, event: "page_view"]}")
- end
-
- defp rate_limited(conn, retry_after) do
- conn
- |> put_resp_header("retry-after", to_string(retry_after))
- |> put_status(:too_many_requests)
- |> json(%{
- error: "rate_limited",
- message: "Too many accounts created from this address. Try again later.",
- retry_after: retry_after
- })
- end
-
- defp site_name(params) do
- case params["project"] || params["name"] do
- value when is_binary(value) ->
- case value |> String.trim() |> String.slice(0, 80) do
- "" -> "My project"
- trimmed -> trimmed
- end
-
- _ ->
- "My project"
- end
- end
-
- defp normalize_email(value) when is_binary(value) do
- case value |> String.trim() |> String.downcase() do
- "" -> nil
- email -> email
- end
- end
-
- defp normalize_email(_), do: nil
-
- defp errors(changeset) do
- Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
- Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
- opts |> Keyword.get(String.to_existing_atom(key), "") |> to_string()
- end)
- end)
- end
-
- defp client_ip(conn) do
- conn.remote_ip |> :inet.ntoa() |> to_string()
- end
+ def options(conn, _params), do: send_resp(conn, 204, "")
end
diff --git a/lib/web_analytics_web/controllers/collect_controller.ex b/lib/web_analytics_web/controllers/collect_controller.ex
index fba1a79..73e735f 100644
--- a/lib/web_analytics_web/controllers/collect_controller.ex
+++ b/lib/web_analytics_web/controllers/collect_controller.ex
@@ -11,6 +11,7 @@ defmodule WebAnalyticsWeb.CollectController do
alias WebAnalytics.Ingest
alias WebAnalytics.Sites
+ alias WebAnalyticsWeb.ClientIP
def create(conn, params) do
received_at = DateTime.utc_now()
@@ -20,7 +21,7 @@ defmodule WebAnalyticsWeb.CollectController do
accepted(conn)
site ->
- ip = client_ip(conn)
+ ip = ClientIP.get(conn)
Ingest.submit(site, params,
received_at: received_at,
@@ -40,22 +41,4 @@ defmodule WebAnalyticsWeb.CollectController do
|> put_resp_header("cache-control", "no-store")
|> send_resp(204, "")
end
-
- # `x-forwarded-for` is client-controlled and only trusted when the deployment
- # says it sits behind a proxy that overwrites it. The value is never stored
- # raw — it is salted and hashed — so a spoofed header costs nothing beyond a
- # slightly noisier anomaly signal.
- defp client_ip(conn) do
- if Application.get_env(:web_analytics, :trust_proxy_headers, false) do
- case get_req_header(conn, "x-forwarded-for") do
- [value | _] -> value |> String.split(",") |> List.first() |> String.trim()
- [] -> remote_ip(conn)
- end
- else
- remote_ip(conn)
- end
- end
-
- defp remote_ip(%Plug.Conn{remote_ip: nil}), do: nil
- defp remote_ip(%Plug.Conn{remote_ip: ip}), do: ip |> :inet.ntoa() |> to_string()
end
diff --git a/lib/web_analytics_web/controllers/landing_controller.ex b/lib/web_analytics_web/controllers/landing_controller.ex
index f6f0c7f..4055c50 100644
--- a/lib/web_analytics_web/controllers/landing_controller.ex
+++ b/lib/web_analytics_web/controllers/landing_controller.ex
@@ -40,6 +40,19 @@ defmodule WebAnalyticsWeb.LandingController do
|> render(:ai)
end
+ def mcp(conn, _params) do
+ conn
+ |> assign(:page_title, "Free MCP Server for AI Agent Analytics — SeriouslySimpleAnalytics")
+ |> assign(
+ :page_description,
+ "Free, open-source MCP server for web and AI agent analytics. Track events, pageviews " <>
+ "and per-user activity, and ask Claude, Cursor, VS Code or ChatGPT about your traffic."
+ )
+ |> assign(:base_url, base_url(conn))
+ |> assign(:tools, WebAnalyticsWeb.MCP.Tools.definitions())
+ |> render(:mcp)
+ end
+
# The canonical host is written into priv/docs/llms.txt literally, so the file
# reads correctly when browsed on GitHub. It is still rewritten per deployment
# here: a self-hosted instance serving the canonical URL would be telling its
@@ -47,7 +60,7 @@ defmodule WebAnalyticsWeb.LandingController do
@canonical_url "https://seriouslysimpleanalytics.com"
def llms(conn, _params) do
- body = String.replace(@llms, @canonical_url, base_url(conn))
+ body = llms_text(base_url(conn))
conn
|> put_resp_content_type("text/plain")
@@ -55,6 +68,9 @@ defmodule WebAnalyticsWeb.LandingController do
|> send_resp(200, body)
end
+ @doc "llms.txt with this deployment's URL in it. Also served as an MCP resource."
+ def llms_text(base_url), do: String.replace(@llms, @canonical_url, base_url)
+
# Real numbers from this deployment's own account, or nil when self-tracking
# is not configured. Nil hides the panel rather than filling it with zeros,
# since an empty proof is worse than no proof.
diff --git a/lib/web_analytics_web/controllers/landing_html.ex b/lib/web_analytics_web/controllers/landing_html.ex
index 6c47af2..99f0558 100644
--- a/lib/web_analytics_web/controllers/landing_html.ex
+++ b/lib/web_analytics_web/controllers/landing_html.ex
@@ -21,7 +21,7 @@ defmodule WebAnalyticsWeb.LandingHTML do
def ai_path, do: @ai_path
attr :current_scope, :map, default: nil
- attr :active, :atom, default: :web, values: [:web, :ai]
+ attr :active, :atom, default: :web, values: [:web, :ai, :mcp]
@doc """
The header shared by both landing pages.
@@ -49,6 +49,12 @@ defmodule WebAnalyticsWeb.LandingHTML do
>
AI tool analytics
+
+ MCP server
+ llms.txt
@@ -163,6 +169,7 @@ defmodule WebAnalyticsWeb.LandingHTML do
·Website analyticsAI tool analytics
+ MCP server
<.link navigate={~p"/dashboard"} class="link link-hover">Dashboard
llms.txt
@@ -177,6 +184,41 @@ defmodule WebAnalyticsWeb.LandingHTML do
"""
end
+ @doc "How to add the MCP server to the clients people actually use."
+ def mcp_clients(base_url) do
+ endpoint = base_url <> "/mcp"
+
+ [
+ {"Claude Code",
+ "claude mcp add --transport http seriouslysimpleanalytics #{endpoint} \\\n --header \"Authorization: Bearer YOUR_API_KEY\""},
+ {"Cursor — ~/.cursor/mcp.json",
+ Jason.encode!(
+ %{
+ "mcpServers" => %{
+ "seriouslysimpleanalytics" => %{
+ "url" => endpoint,
+ "headers" => %{"Authorization" => "Bearer YOUR_API_KEY"}
+ }
+ }
+ },
+ pretty: true
+ )},
+ {"VS Code — .vscode/mcp.json",
+ Jason.encode!(
+ %{
+ "servers" => %{
+ "seriouslysimpleanalytics" => %{
+ "type" => "http",
+ "url" => endpoint,
+ "headers" => %{"Authorization" => "Bearer YOUR_API_KEY"}
+ }
+ }
+ },
+ pretty: true
+ )}
+ ]
+ end
+
@doc "The install snippet, for sites that also want browser tracking."
def script_tag(base_url, site_key) do
~s||
diff --git a/lib/web_analytics_web/controllers/landing_html/mcp.html.heex b/lib/web_analytics_web/controllers/landing_html/mcp.html.heex
new file mode 100644
index 0000000..a8c1bca
--- /dev/null
+++ b/lib/web_analytics_web/controllers/landing_html/mcp.html.heex
@@ -0,0 +1,156 @@
+
+ The MCP server for your analytics — inside Claude, Cursor and ChatGPT
+
+
+ SeriouslySimpleAnalytics is a free, open-source, unlimited Model Context Protocol
+ server for web analytics and AI agent analytics. Your assistant can create an account,
+ add tracking to a project, record events, and answer "how is my site doing?" from real
+ traffic — visitors, top pages, referrers, custom events, revenue metrics, individual
+ users and AI crawler traffic (GPTBot, ClaudeBot, PerplexityBot).
+
+
+
+
+
Remote MCP endpoint
+
+
{@base_url}/mcp
+
+
+
+ Streamable HTTP. Tracking tools need nothing; report tools need your account's API key as
+ Authorization: Bearer ssa_…
+ — create one on <.link navigate={~p"/getting-started"} class="link">Getting started, or let
+ create_analytics_account
+ hand your assistant one.
+
+
+ Listed in the official MCP Registry as com.seriouslysimpleanalytics/analytics-mcp-server.
+
+
+
+
+
+
+
+
Connect it
+
+ Any MCP client that speaks Streamable HTTP works. In Claude or ChatGPT, add a custom
+ connector with the endpoint above: tracking works straight away, and the report tools
+ need a client that can send a header.
+
+
+
+
+
+
{client}
+
+
+
{config}
+
+
+
+
+
+
+
+
{length(@tools)} tools
+
+ Three that write, which need no key, and the rest read the reports your dashboard shows.
+ Every report narrows by time range, project, domain and user.
+
+
+
+
+
+
+
Tool
+
What it answers
+
Key
+
+
+
+
+
+
{tool["name"]}
+
{tool["title"]}
+
+
+ {String.replace_suffix(tool["description"], " Needs the account's API key.", "")}
+
+ Nothing, and there is no traffic cap. The MCP server, the website tracker and the
+ event API are the same free account.
+
+
+
+
What can it track?
+
+ Websites, through one script tag, and anything else through one URL: AI agents, MCP
+ servers, CLIs, backends and cron jobs. Pageviews, custom events, numeric metrics like
+ revenue or tokens, and per-user activity.
+
+
+
+
Why is the API key only for reading?
+
+ An account ID is public — it sits in the page source of every tracked site — so it can
+ only ever allow sending. Reading a report back needs a secret, shown once and stored
+ only as a hash.
+
+
+
+
Is there a non-MCP way in?
+
+ Yes. llms.txt
+ documents the plain HTTP event API, and the
+ <.link navigate={~p"/dashboard"} class="link">dashboard
+ shows everything the tools read.
+
+
+
+
+
+ <.site_footer />
+
+ <%!-- Carries the copy buttons, as on the other landing pages. --%>
+
+
diff --git a/lib/web_analytics_web/controllers/mcp_controller.ex b/lib/web_analytics_web/controllers/mcp_controller.ex
new file mode 100644
index 0000000..a3df4fb
--- /dev/null
+++ b/lib/web_analytics_web/controllers/mcp_controller.ex
@@ -0,0 +1,135 @@
+defmodule WebAnalyticsWeb.MCPController do
+ @moduledoc """
+ The MCP server's Streamable HTTP transport, at `/mcp`.
+
+ One POST per JSON-RPC message, answered with `application/json`. There is no
+ server-to-client stream to open: every tool here finishes in one request, and
+ the server keeps no session, so a GET or DELETE on the endpoint is answered
+ with 405 as both protocol eras allow.
+
+ Origins are all accepted, deliberately. The rule that servers validate
+ `Origin` exists to stop DNS rebinding against servers on a private network,
+ which reach things only the visitor's browser can. This one is on the public
+ internet, reads no cookies, and unlocks nothing without a key the calling page
+ would have to already hold — a hostile page gets exactly what curl gets.
+ """
+ use WebAnalyticsWeb, :controller
+
+ alias WebAnalytics.ApiKeys
+ alias WebAnalyticsWeb.ClientIP
+ alias WebAnalyticsWeb.MCP.Server
+
+ @auth_path Path.expand("../../../priv/mcp/mcp-registry-auth", __DIR__)
+ @external_resource @auth_path
+ @registry_auth (case File.read(@auth_path) do
+ {:ok, contents} -> String.trim(contents)
+ {:error, _} -> nil
+ end)
+
+ def handle(conn, _params) do
+ ctx = context(conn)
+
+ case conn.body_params do
+ # JSON-RPC batches existed only in 2025-03-26. Each message is handled on
+ # its own and the answers are returned together, notifications omitted.
+ %{"_json" => messages} when is_list(messages) and messages != [] ->
+ replies =
+ messages
+ |> Enum.map(&Server.handle(&1, ctx))
+ |> Enum.flat_map(fn
+ {_status, nil} -> []
+ {_status, body} -> [body]
+ end)
+
+ if replies == [], do: send_resp(conn, 202, ""), else: reply(conn, 200, replies)
+
+ message when is_map(message) and map_size(message) > 0 ->
+ case Server.handle(message, ctx) do
+ {status, nil} -> send_resp(conn, status, "")
+ {status, body} -> reply(conn, status, body)
+ end
+
+ _ ->
+ reply(
+ conn,
+ 400,
+ Server.error(nil, -32700, "Parse error: expected a JSON-RPC message body")
+ )
+ end
+ end
+
+ # A person who opens the endpoint in a browser gets the page about it; a
+ # client asking for a server-to-client stream gets told there is none.
+ def stream(conn, _params) do
+ if conn.method == "GET" and html?(conn) do
+ redirect(conn, to: ~p"/analytics-mcp-server")
+ else
+ conn
+ |> put_resp_header("allow", "POST, OPTIONS")
+ |> reply(
+ 405,
+ Server.error(nil, -32000, "Method not allowed: POST JSON-RPC messages to this endpoint")
+ )
+ end
+ end
+
+ def options(conn, _params), do: send_resp(conn, 204, "")
+
+ def registry_auth(conn, _params) do
+ case @registry_auth do
+ nil ->
+ send_resp(conn, 404, "")
+
+ proof ->
+ conn
+ |> put_resp_content_type("text/plain")
+ |> send_resp(200, proof)
+ end
+ end
+
+ defp context(conn) do
+ {auth, site} = authenticate(conn)
+
+ %{
+ base_url: conn |> url(~p"/") |> String.trim_trailing("/"),
+ ip: ClientIP.get(conn),
+ headers: conn.req_headers,
+ auth: auth,
+ site: site
+ }
+ end
+
+ defp authenticate(conn) do
+ token =
+ case get_req_header(conn, "authorization") do
+ ["Bearer " <> token | _] -> String.trim(token)
+ ["bearer " <> token | _] -> String.trim(token)
+ _ -> conn |> get_req_header("x-api-key") |> List.first()
+ end
+
+ case token do
+ nil ->
+ {:none, nil}
+
+ "" ->
+ {:none, nil}
+
+ token ->
+ case ApiKeys.authenticate(token) do
+ {:ok, site} -> {:valid, site}
+ :error -> {:invalid, nil}
+ end
+ end
+ end
+
+ defp html?(conn) do
+ conn |> get_req_header("accept") |> Enum.any?(&String.contains?(&1, "text/html"))
+ end
+
+ defp reply(conn, status, body) do
+ conn
+ |> put_resp_header("cache-control", "no-store")
+ |> put_resp_content_type("application/json")
+ |> send_resp(status, Jason.encode!(body))
+ end
+end
diff --git a/lib/web_analytics_web/controllers/ping_controller.ex b/lib/web_analytics_web/controllers/ping_controller.ex
index 387dac3..a2966de 100644
--- a/lib/web_analytics_web/controllers/ping_controller.ex
+++ b/lib/web_analytics_web/controllers/ping_controller.ex
@@ -16,283 +16,17 @@ defmodule WebAnalyticsWeb.PingController do
"""
use WebAnalyticsWeb, :controller
- alias WebAnalytics.Geo
- alias WebAnalytics.Geo.Countries
- alias WebAnalytics.Ingest
- alias WebAnalytics.Sites
-
- # Names that mean "someone looked at something", which are recorded as real
- # pageviews so they land in the pages and flow reports rather than only in the
- # event list.
- @pageview_events ~w(page_view pageview pv view screen screen_view)
-
- @reserved ~w(uid id site u type channel project app event name sid session path page
- title ref referrer visitor v format bot agent ai tz timezone
- email contact user user_id userid
- c city cc county s_p state province region n nation country)
+ alias WebAnalytics.Ingest.Ping
+ alias WebAnalyticsWeb.ClientIP
# A 1x1 transparent GIF, for callers that can only embed an image.
@pixel Base.decode64!("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
- # How long consecutive pings from the same caller are treated as one session
- # when no session id is supplied. Thirty minutes is the convention analytics
- # has used for inactivity windows for decades.
- @session_window_seconds 1_800
-
+ # What a ping means lives in WebAnalytics.Ingest.Ping, shared with the MCP
+ # server's track_event tool. This module is only the HTTP shape around it.
def ping(conn, params) do
- received_at = DateTime.utc_now()
-
- case Sites.fetch_site_by_key(account_id(params)) do
- nil ->
- respond(conn, params)
-
- site ->
- ip = client_ip(conn)
- ip_hash = Ingest.hash_ip(ip, site)
-
- Ingest.submit(site, payload(params, received_at, session_token(params, site, ip_hash)),
- received_at: received_at,
- user_id: user_id(params),
- user_traits: user_traits(params),
- ip_hash: ip_hash,
- ip_masked: Ingest.mask_ip(ip),
- location: location(params, conn, ip),
- project: param(params, ~w(project app)),
- channel: param(params, ~w(type channel)) || "ai",
- agent_name: param(params, ~w(name agent ai)),
- contact_email: param(params, ~w(email contact))
- )
-
- respond(conn, params)
- end
- end
-
- # -- user ----------------------------------------------------------------
-
- @doc false
- # The caller's own ID for who this ping is about: an account, a customer, a
- # mailbox. Accepted as a number as well as a string, because a JSON body
- # carries ids as numbers far more often than not, and param/2 — built for
- # query strings — would drop `"user": 42` without a word.
- #
- # `visitor` is the older name for the same idea and still counts, so a tool
- # already sending it shows up by user without changing anything.
- defp user_id(params) do
- Enum.find_value(~w(user user_id userid visitor), fn key ->
- case Map.get(params, key) do
- value when is_integer(value) -> Integer.to_string(value)
- value when is_binary(value) -> blank_to_nil(value)
- _ -> nil
- end
- end)
- end
-
- @doc false
- # Every other identifier, by prefix: user_domain, user_address, user_plan.
- # One convention rather than a fixed list, because what identifies a user is
- # the caller's business — an email provider has an account, a domain and an
- # address; a marketplace has a seller and a wallet.
- #
- # They stay on the event as ordinary attributes too. This copy is what lets
- # the dashboard show them beside the user without reading every event.
- defp user_traits(params) do
- params
- |> Enum.filter(fn {key, value} ->
- is_binary(key) and String.starts_with?(key, "user_") and key not in @reserved and
- (is_binary(value) or is_number(value) or is_boolean(value))
- end)
- |> Map.new(fn {key, value} ->
- {String.replace_prefix(key, "user_", ""), to_string(value)}
- end)
- end
-
- defp blank_to_nil(value) do
- case String.trim(value) do
- "" -> nil
- trimmed -> trimmed
- end
- end
-
- # -- location ------------------------------------------------------------
-
- @doc false
- # A caller's own location parameters always win over anything this server
- # could work out.
- #
- # That is not a preference, it is the only correct answer for this endpoint:
- # a ping arrives from wherever the tool runs — a laptop, a container, a
- # serverless region three countries away — so its source address says where
- # the *software* is, not where its user is. Resolving the address would
- # produce a confident, wrong answer. Falling back to it at all is only
- # reasonable for the browser tracker, where the connection really is the
- # visitor's.
- defp location(params, conn, ip) do
- supplied = %{
- city: param(params, ~w(c city)),
- county: param(params, ~w(cc county)),
- region: param(params, ~w(s_p state province region)),
- country: param(params, ~w(n nation country))
- }
-
- if Enum.any?(Map.values(supplied), &is_binary/1) do
- code = Countries.code_for(supplied.country)
-
- %{
- Geo.empty()
- | city: supplied.city,
- county: supplied.county,
- region: supplied.region,
- country: Countries.name(code) || supplied.country,
- country_code: code,
- source: "client"
- }
- else
- Ingest.locate(conn.req_headers, ip)
- end
- end
-
- # -- payload -------------------------------------------------------------
-
- defp payload(params, received_at, token) do
- # `name` is deliberately NOT an alias for `event` any more. It now identifies
- # the AI tool doing the reporting, and one parameter cannot mean two things:
- # a caller sending name=Claude would otherwise have silently renamed its
- # event instead of identifying itself.
- event_name = param(params, ~w(event)) || "ping"
- path = param(params, ~w(path page))
- now = DateTime.to_unix(received_at, :millisecond)
-
- %{
- "k" => account_id(params),
- "s" => token,
- # A user id is the best visitor identity a ping can have: it is what makes
- # "visitors" count people rather than sessions for a tool that says who
- # it is acting for.
- "v" => param(params, ~w(visitor v)) || user_id(params),
- "t" => now,
- "e" => [init_event(params, now) | [body_event(event_name, path, params, now)]]
- }
- end
-
- # The caller's user agent is deliberately *not* forwarded for classification.
- # A ping is a tool reporting its own usage, and most such callers are a script
- # or an HTTP library — classifying them by user agent would file every one as
- # a crawler and quietly filter the owner's own telemetry out of their own
- # reports. Only an explicit `bot=` marks a ping as automated.
- defp init_event(params, now) do
- %{
- "n" => "init",
- "t" => now,
- "ref" => param(params, ~w(ref referrer)),
- "bot" => param(params, ~w(bot agent)),
- "tz" => param(params, ~w(tz timezone)),
- "hb" => 10_000
- }
- end
-
- # `event=page_view` with a path is a real pageview; everything else is a
- # named event. That mapping is documented, so a caller gets pages and flow
- # reporting by naming the event the obvious thing rather than by learning a
- # second parameter.
- defp body_event(event_name, path, params, now) do
- if String.downcase(event_name) in @pageview_events and path do
- # No `seq` and no `from`: the server continues the session's sequence and
- # links this page to the one before it, which is what builds the flow
- # graph without the caller having to track any of it.
- %{
- "n" => "pv",
- "t" => now,
- "path" => path,
- "title" => param(params, ~w(title)),
- "ref" => param(params, ~w(ref referrer))
- }
- else
- # No `pv` either: the event attaches to whatever page the session is on.
- %{
- "n" => "event",
- "t" => now,
- "name" => event_name,
- "text" => param(params, ~w(title)),
- "data" => extras(params)
- }
- end
- end
-
- # Anything the caller invented, kept as attributes.
- defp extras(params) do
- params
- |> Enum.reject(fn {key, value} -> key in @reserved or is_nil(value) end)
- |> Enum.take(20)
- |> Map.new(fn {key, value} -> {to_string(key), attribute_value(value)} end)
- end
-
- # A query string only ever carries strings, but a JSON body carries whatever
- # the caller put in it. to_string/1 has no clause for a map or a list, so
- # "meta": {"repo": "x"} raised — and the whole ping was lost with a 500, not
- # just the field that could not be stored. An agent posting structured
- # context would have been silently dropping every event it sent.
- #
- # Nested values are kept as their JSON rather than thrown away, capped so one
- # large blob cannot bloat a row. They never become metrics — a key is a
- # metric only when its values are plain numbers — which is correct: there is
- # nothing to sum in an object.
- defp attribute_value(value) when is_binary(value), do: value
- defp attribute_value(value) when is_number(value) or is_boolean(value), do: to_string(value)
-
- defp attribute_value(value) when is_map(value) or is_list(value) do
- value |> Jason.encode!() |> String.slice(0, 1_000)
- end
-
- defp attribute_value(value), do: inspect(value)
-
- @doc false
- # A session id from the caller is authoritative. Without one, pings are grouped
- # by who and what they came from within a rolling half-hour, so that a tool
- # that never passes `sid` still produces sessions — and therefore page flow —
- # rather than a pile of one-event sessions that no report can connect.
- #
- # The window tumbles rather than sliding, so a long run can straddle a boundary
- # and split in two. That is the price of deriving a session without a lookup on
- # every ping; `sid` is there for callers that need exactness.
- defp session_token(params, site, ip_hash) do
- case param(params, ~w(sid session)) do
- nil -> derived_token(params, site, ip_hash)
- explicit -> explicit
- end
- end
-
- # A user id comes first. A backend reporting for many users sends every ping
- # from one address, and grouping by address would fold all of them into a
- # single session — one "user" doing everything at once.
- defp derived_token(params, site, ip_hash) do
- project = param(params, ~w(project app)) || "-"
- who = user_id(params) || param(params, ~w(visitor v)) || ip_hash || "anon"
- window = div(System.system_time(:second), @session_window_seconds)
-
- digest =
- :sha256
- |> :crypto.hash([site.key, "|", project, "|", who, "|", Integer.to_string(window)])
- |> Base.url_encode64(padding: false)
- |> binary_part(0, 22)
-
- "auto-" <> digest
- end
-
- defp account_id(params), do: param(params, ~w(uid id site u))
-
- defp param(params, keys) do
- Enum.find_value(keys, fn key ->
- case Map.get(params, key) do
- value when is_binary(value) ->
- case String.trim(value) do
- "" -> nil
- trimmed -> trimmed
- end
-
- _ ->
- nil
- end
- end)
+ Ping.submit(params, ip: ClientIP.get(conn), headers: conn.req_headers)
+ respond(conn, params)
end
# -- response ------------------------------------------------------------
@@ -302,7 +36,7 @@ defmodule WebAnalyticsWeb.PingController do
defp respond(conn, params) do
conn = put_resp_header(conn, "cache-control", "no-store, no-cache, must-revalidate")
- case param(params, ~w(format)) do
+ case params["format"] do
"gif" ->
conn |> put_resp_content_type("image/gif") |> send_resp(200, @pixel)
@@ -313,18 +47,4 @@ defmodule WebAnalyticsWeb.PingController do
send_resp(conn, 204, "")
end
end
-
- defp client_ip(conn) do
- if Application.get_env(:web_analytics, :trust_proxy_headers, false) do
- case get_req_header(conn, "x-forwarded-for") do
- [value | _] -> value |> String.split(",") |> List.first() |> String.trim()
- [] -> remote_ip(conn)
- end
- else
- remote_ip(conn)
- end
- end
-
- defp remote_ip(%Plug.Conn{remote_ip: nil}), do: nil
- defp remote_ip(%Plug.Conn{remote_ip: ip}), do: ip |> :inet.ntoa() |> to_string()
end
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.
+
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"
+ />
+
+ 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/lib/web_analytics_web/live/getting_started_live.ex b/lib/web_analytics_web/live/getting_started_live.ex
index 845b02b..54502b8 100644
--- a/lib/web_analytics_web/live/getting_started_live.ex
+++ b/lib/web_analytics_web/live/getting_started_live.ex
@@ -12,6 +12,7 @@ defmodule WebAnalyticsWeb.GettingStartedLive do
import WebAnalyticsWeb.IntegrationComponents, only: [integration_chat: 1]
+ alias WebAnalytics.ApiKeys
alias WebAnalytics.Sites
@impl true
@@ -30,7 +31,42 @@ defmodule WebAnalyticsWeb.GettingStartedLive do
@impl true
def handle_params(params, _uri, socket) do
- {:noreply, assign(socket, :site, resolve_site(socket.assigns.sites, params["site"]))}
+ site = resolve_site(socket.assigns.sites, params["site"])
+
+ {:noreply,
+ socket
+ |> assign(:site, site)
+ |> assign(:api_keys, ApiKeys.list(site))
+ # The one moment a key exists in readable form. Kept only in this socket,
+ # so navigating away or reloading is the end of it.
+ |> assign(:new_token, nil)}
+ end
+
+ @impl true
+ def handle_event("create_api_key", params, socket) do
+ case ApiKeys.create(socket.assigns.site, params["name"]) do
+ {:ok, token, _key} ->
+ {:noreply,
+ socket
+ |> assign(:new_token, token)
+ |> assign(:api_keys, ApiKeys.list(socket.assigns.site))}
+
+ {:error, :too_many} ->
+ {:noreply,
+ put_flash(socket, :error, "Revoke an unused key first — an account can have 20.")}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Could not create a key.")}
+ end
+ end
+
+ def handle_event("revoke_api_key", %{"id" => id}, socket) do
+ ApiKeys.revoke(socket.assigns.site, id)
+ {:noreply, assign(socket, :api_keys, ApiKeys.list(socket.assigns.site))}
+ end
+
+ def handle_event("dismiss_token", _params, socket) do
+ {:noreply, assign(socket, :new_token, nil)}
end
defp resolve_site(sites, key) do
@@ -43,6 +79,11 @@ defmodule WebAnalyticsWeb.GettingStartedLive do
~s||
end
+ defp claude_command(endpoint, token) do
+ "claude mcp add --transport http seriouslysimpleanalytics #{endpoint}/mcp " <>
+ "--header \"Authorization: Bearer #{token}\""
+ end
+
defp agent_prompt(site, endpoint) do
WebAnalyticsWeb.DashboardLive.agent_prompt(site, endpoint)
end
diff --git a/lib/web_analytics_web/live/getting_started_live.html.heex b/lib/web_analytics_web/live/getting_started_live.html.heex
index ee37a12..caa3732 100644
--- a/lib/web_analytics_web/live/getting_started_live.html.heex
+++ b/lib/web_analytics_web/live/getting_started_live.html.heex
@@ -57,6 +57,119 @@
+ <%!-- Reading reports is the one thing an account ID must never allow, so
+ it is the one thing here that needs a secret. --%>
+
+
+
MCP Server
+
+ Connect Claude, Cursor, VS Code or any MCP client to this account at {base_url()}/mcp.
+ Tracking events needs no key. Asking about your traffic, events, metrics and users
+ needs an API key — create one per client, and revoke it here when you stop using it.
+ <.link navigate={~p"/analytics-mcp-server"} class="link">Setup for each client
+
+
+
+
+
+ Copy this key now — it will not be shown again.
+
+
+