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
8 changes: 8 additions & 0 deletions lib/cleat/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Cleat.CLI do
"""

alias Cleat.Commands.{
Analytics,
Apps,
Cancel,
Completions,
Expand Down Expand Up @@ -131,6 +132,7 @@ defmodule Cleat.CLI do
defp dispatch(["env" | rest], opts), do: run(Env.run(rest, opts))
defp dispatch(["events" | rest], opts), do: run(Events.run(rest, opts))
defp dispatch(["signals" | rest], opts), do: run(Signals.run(rest, opts))
defp dispatch(["analytics" | rest], opts), do: run(Analytics.run(rest, opts))

defp dispatch(["deploy" | rest], opts), do: run(Deploy.run(List.first(rest), opts))
defp dispatch(["drop" | rest], opts), do: run(Drop.run(rest, opts))
Expand Down Expand Up @@ -234,6 +236,12 @@ defmodule Cleat.CLI do
List traces or open a waterfall
signals sampling APP [--rate 0..1] Read or set trace sampling

Analytics
analytics requested Most requested apps · 24h (Caddy access log)
analytics visited Most visited apps by pageviews (JS sidecar)
analytics show APP [--range 24h|7d|90d]
Pageviews, uniques, paths, referrers, UTM

Environment
env list APP [--branch B] [--reveal] List env vars (secrets masked)
env set APP K=V [K=V ...] \\
Expand Down
15 changes: 15 additions & 0 deletions lib/cleat/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ defmodule Cleat.Client do
request(client, :get, "/api/v1/signals/pages", params: params)
end

@doc "Most requested apps 24h on the active server (`GET /api/v1/analytics/requested`)."
def analytics_requested(%__MODULE__{} = client) do
request(client, :get, "/api/v1/analytics/requested")
end

@doc "Most visited apps 24h on the active server (`GET /api/v1/analytics/visited`)."
def analytics_visited(%__MODULE__{} = client) do
request(client, :get, "/api/v1/analytics/visited")
end

@doc "Pageview summary for one app (`GET /api/v1/apps/:id/analytics`)."
def analytics_summary(%__MODULE__{} = client, app, params \\ %{}) when is_map(params) do
request(client, :get, "/api/v1/apps/#{app}/analytics", params: params)
end

@doc "Open signal alerts (`GET /api/v1/signals/alerts`)."
def signals_alerts(%__MODULE__{} = client), do: request(client, :get, "/api/v1/signals/alerts")

Expand Down
150 changes: 150 additions & 0 deletions lib/cleat/commands/analytics.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
defmodule Cleat.Commands.Analytics do
@moduledoc """
`cleat analytics` — product usage from the panel.

`requested` reads Caddy's access log (HTTP hits); `visited` and `show` read
the pageview sidecar (JS, inject-on only). The two are not interchangeable.
"""

alias Cleat.{Client, Commands, Output}

@usage """
usage:
cleat analytics requested
cleat analytics visited
cleat analytics show APP [--range 24h|7d|90d]
"""

def run(["requested" | _rest], opts), do: requested(opts)
def run(["visited" | _rest], opts), do: visited(opts)
def run(["show", app | _rest], opts), do: show(app, opts)
def run(["show"], _opts), do: {:error, @usage}
def run(_args, _opts), do: {:error, @usage}

defp requested(opts) do
with {:ok, client} <- Commands.client(opts),
{:ok, body} <- Client.analytics_requested(client) do
rows = Commands.data(body)
if opts[:json], do: Output.json(rows), else: print_requested(rows)
:ok
end
end

defp visited(opts) do
with {:ok, client} <- Commands.client(opts),
{:ok, body} <- Client.analytics_visited(client) do
rows = Commands.data(body)
stale? = body["stale"] == true

if opts[:json] do
Output.json(%{"data" => rows, "stale" => stale?})
else
print_visited(rows, stale?)
end

:ok
end
end

defp show(app, opts) do
params = compact(%{"range" => opts[:range]})

with {:ok, client} <- Commands.client(opts),
{:ok, body} <- Client.analytics_summary(client, app, params) do
data = Commands.data(body)
if opts[:json], do: Output.json(data), else: print_summary(data)
:ok
end
end

defp print_requested([]) do
Output.info("No request samples on the active server.")
end

defp print_requested(rows) do
table =
Enum.map(rows, fn row ->
[row["slug"], row["host"], row["requests"]]
end)

Output.table(table, ["APP", "HOST", "REQUESTS"])
end

defp print_visited(rows, stale?) do
cond do
stale? and rows == [] ->
Output.info("Analytics stale: the sidecar did not answer.")

stale? ->
Output.info("Analytics stale: the sidecar did not answer, showing the last snapshot.")
print_visited_rows(rows)

rows == [] ->
Output.info("No pageviews in this window.")

true ->
print_visited_rows(rows)
end
end

defp print_visited_rows(rows) do
table =
Enum.map(rows, fn row ->
[row["slug"], row["host"], row["pageviews"]]
end)

Output.table(table, ["APP", "HOST", "PAGEVIEWS"])
end

defp print_summary(data) do
Output.info(
"#{data["slug"]} range=#{data["range"]} pageviews=#{data["pageviews"]} uniques=#{data["uniques"]}"
)

if data["stale"] do
Output.info("STALE the sidecar did not answer — numbers may be missing.")
end

print_rows("PATHS", "PATH", data["paths"])
print_rows("REFERRERS", "REFERRER", data["referrers"])
print_utm(data["utm"])
end

defp print_rows(label, column, rows) do
rows = rows || []

if rows == [] do
Output.info("#{label} —")
else
table = Enum.map(rows, fn row -> [row["path"] || row["referrer"], row["pageviews"]] end)
Output.table(table, [column, "PAGEVIEWS"])
end
end

defp print_utm(rows) do
rows = rows || []

if rows == [] do
Output.info("UTM —")
else
table =
Enum.map(rows, fn row ->
[
Enum.join(
[row["source"], row["medium"], row["campaign"]] |> Enum.reject(&is_nil/1),
"/"
),
row["pageviews"]
]
end)

Output.table(table, ["SOURCE/MEDIUM/CAMPAIGN", "PAGEVIEWS"])
end
end

defp compact(params) do
params
|> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
|> Map.new()
end
end
55 changes: 55 additions & 0 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,61 @@ defmodule Cleat.MCP.Tools do
end)
end
},
%{
"name" => "analytics_requested",
"description" =>
"Most requested apps in the last 24h on the active server, from Caddy's access log (HTTP hits, not pageviews).",
"inputSchema" => %{
"type" => "object",
"properties" => %{"panel" => @panel, "token" => @token}
},
"handler" => fn args ->
with_client(args, fn client ->
with {:ok, body} <- Client.analytics_requested(client), do: {:ok, data_text(body)}
end)
end
},
%{
"name" => "analytics_visited",
"description" =>
"Most visited apps in the last 24h by JS pageviews on the active server (inject-on only). stale=true means the sidecar did not answer.",
"inputSchema" => %{
"type" => "object",
"properties" => %{"panel" => @panel, "token" => @token}
},
"handler" => fn args ->
with_client(args, fn client ->
with {:ok, body} <- Client.analytics_visited(client) do
{:ok, json(%{"data" => Commands.data(body), "stale" => body["stale"] == true})}
end
end)
end
},
%{
"name" => "analytics_summary",
"description" =>
"Pageview summary for one app: pageviews, uniques, series, top paths, referrers and UTM. range is 24h, 7d or 90d; stale=true means the sidecar did not answer.",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"app" => @app,
"range" => %{"type" => "string", "description" => "24h, 7d or 90d"},
"panel" => @panel,
"token" => @token
},
"required" => ["app"]
},
"handler" => fn args ->
params = compact_params(%{"range" => args["range"]})

with_client(args, fn client ->
with {:ok, body} <-
Client.analytics_summary(client, args["app"], params) do
{:ok, data_text(body)}
end
end)
end
},
%{
"name" => "apps_list",
"description" => "List apps",
Expand Down
7 changes: 7 additions & 0 deletions test/cleat/cli_mcp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,11 @@ defmodule Cleat.CLIMcpTest do
assert usage =~ "signals traces"
assert usage =~ "signals sampling"
end

test "usage documents the analytics commands" do
usage = CLI.usage()
assert usage =~ "analytics requested"
assert usage =~ "analytics visited"
assert usage =~ "analytics show"
end
end
39 changes: 39 additions & 0 deletions test/cleat/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,45 @@ defmodule Cleat.ClientTest do
Client.signals_pages(client, %{"app" => "new-lp", "range" => "24h"})
end

test "analytics_requested GETs /api/v1/analytics/requested" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "GET"
assert conn.request_path == "/api/v1/analytics/requested"
Req.Test.json(conn, %{"data" => [%{"slug" => "nfe-facil", "requests" => 3}]})
end)

client = Client.new("https://panel.test", "tok")

assert {:ok, %{"data" => [%{"slug" => "nfe-facil"}]}} =
Client.analytics_requested(client)
end

test "analytics_visited GETs /api/v1/analytics/visited" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "GET"
assert conn.request_path == "/api/v1/analytics/visited"
Req.Test.json(conn, %{"data" => [], "stale" => true})
end)

client = Client.new("https://panel.test", "tok")

assert {:ok, %{"stale" => true}} = Client.analytics_visited(client)
end

test "analytics_summary GETs /api/v1/apps/:id/analytics with the range" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "GET"
assert conn.request_path == "/api/v1/apps/fagulha-app/analytics"
assert URI.decode_query(conn.query_string)["range"] == "7d"
Req.Test.json(conn, %{"data" => %{"slug" => "fagulha-app", "range" => "7d"}})
end)

client = Client.new("https://panel.test", "tok")

assert {:ok, %{"data" => %{"range" => "7d"}}} =
Client.analytics_summary(client, "fagulha-app", %{"range" => "7d"})
end

test "query_app POSTs /api/v1/apps/:id/query" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "POST"
Expand Down
Loading
Loading