diff --git a/lib/cleat/cli.ex b/lib/cleat/cli.ex index 19aa06c..5252c7d 100644 --- a/lib/cleat/cli.ex +++ b/lib/cleat/cli.ex @@ -4,6 +4,7 @@ defmodule Cleat.CLI do """ alias Cleat.Commands.{ + Analytics, Apps, Cancel, Completions, @@ -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)) @@ -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 ...] \\ diff --git a/lib/cleat/client.ex b/lib/cleat/client.ex index c7312cd..e468688 100644 --- a/lib/cleat/client.ex +++ b/lib/cleat/client.ex @@ -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") diff --git a/lib/cleat/commands/analytics.ex b/lib/cleat/commands/analytics.ex new file mode 100644 index 0000000..f8721ab --- /dev/null +++ b/lib/cleat/commands/analytics.ex @@ -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 diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index 47fc16d..8306bb8 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -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", diff --git a/test/cleat/cli_mcp_test.exs b/test/cleat/cli_mcp_test.exs index bc09100..db9b242 100644 --- a/test/cleat/cli_mcp_test.exs +++ b/test/cleat/cli_mcp_test.exs @@ -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 diff --git a/test/cleat/client_test.exs b/test/cleat/client_test.exs index 4a4874d..a3bdf4f 100644 --- a/test/cleat/client_test.exs +++ b/test/cleat/client_test.exs @@ -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" diff --git a/test/cleat/commands/analytics_test.exs b/test/cleat/commands/analytics_test.exs new file mode 100644 index 0000000..0e0c71d --- /dev/null +++ b/test/cleat/commands/analytics_test.exs @@ -0,0 +1,143 @@ +defmodule Cleat.Commands.AnalyticsTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias Cleat.Commands.Analytics + + @conn %{panel: "https://panel.test", token: "tok"} + + setup do + Application.put_env(:cleat_cli, :req_plug, {Req.Test, __MODULE__}) + on_exit(fn -> Application.delete_env(:cleat_cli, :req_plug) end) + :ok + end + + test "requested prints the 24h ranking with host" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/analytics/requested" + + Req.Test.json(conn, %{ + "data" => [ + %{ + "app_id" => 55, + "slug" => "nfe-facil", + "name" => "NFe Fácil", + "host" => "nfe.gestaobem.com", + "requests" => 40 + } + ] + }) + end) + + output = capture_io(fn -> assert :ok = Analytics.run(["requested"], @conn) end) + + assert output =~ "nfe-facil" + assert output =~ "nfe.gestaobem.com" + assert output =~ "40" + end + + test "requested with --json prints the rows" do + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, %{"data" => [%{"slug" => "nfe-facil", "requests" => 1}]}) + end) + + output = + capture_io(fn -> assert :ok = Analytics.run(["requested"], Map.put(@conn, :json, true)) end) + + assert Jason.decode!(output) == [%{"slug" => "nfe-facil", "requests" => 1}] + end + + test "visited prints the ranking and notes staleness" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/analytics/visited" + + Req.Test.json(conn, %{ + "data" => [ + %{"slug" => "fagulha-app", "host" => "fagulha.apps.gestaobem.com", "pageviews" => 12} + ], + "stale" => true + }) + end) + + output = capture_io(fn -> assert :ok = Analytics.run(["visited"], @conn) end) + + assert output =~ "stale" + assert output =~ "fagulha-app" + assert output =~ "12" + end + + test "visited tells when there is nothing" do + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, %{"data" => [], "stale" => false}) + end) + + output = capture_io(fn -> assert :ok = Analytics.run(["visited"], @conn) end) + assert output =~ "No pageviews" + end + + test "show forwards the range and prints the summary" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/apps/fagulha-app/analytics" + assert URI.decode_query(conn.query_string)["range"] == "7d" + + Req.Test.json(conn, %{ + "data" => %{ + "app_id" => 72, + "slug" => "fagulha-app", + "range" => "7d", + "pageviews" => 42, + "uniques" => 7, + "series" => [], + "paths" => [%{"path" => "/", "pageviews" => 30}], + "referrers" => [%{"referrer" => "google.com", "pageviews" => 4}], + "utm" => [ + %{"source" => "news", "medium" => "email", "campaign" => "launch", "pageviews" => 2} + ], + "stale" => false + } + }) + end) + + output = + capture_io(fn -> + assert :ok = Analytics.run(["show", "fagulha-app"], Map.put(@conn, :range, "7d")) + end) + + assert output =~ "fagulha-app" + assert output =~ "42" + assert output =~ "google.com" + assert output =~ "news/email/launch" + end + + test "show flags a stale summary" do + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, %{ + "data" => %{ + "slug" => "fagulha-app", + "range" => "24h", + "pageviews" => 0, + "uniques" => 0, + "series" => [], + "paths" => [], + "referrers" => [], + "utm" => [], + "stale" => true + } + }) + end) + + output = capture_io(fn -> assert :ok = Analytics.run(["show", "fagulha-app"], @conn) end) + assert output =~ "STALE" + end + + test "show without an app returns usage" do + assert {:error, message} = Analytics.run(["show"], @conn) + assert message =~ "usage" + end + + test "an unknown subcommand returns usage" do + assert {:error, message} = Analytics.run(["nope"], @conn) + assert message =~ "usage" + end +end diff --git a/test/cleat/mcp/tools_test.exs b/test/cleat/mcp/tools_test.exs index d793617..cc9fb06 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -39,6 +39,9 @@ defmodule Cleat.MCP.ToolsTest do "signals_alerts", "signals_traces", "signals_set_sampling", + "analytics_requested", + "analytics_visited", + "analytics_summary", "apps_list", "apps_show", "apps_create", @@ -265,6 +268,61 @@ defmodule Cleat.MCP.ToolsTest do assert %{"slug" => "new-lp", "requested" => [%{"path" => "/blog"}]} = Jason.decode!(text) 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) + + assert {:ok, text} = + Tools.call("analytics_requested", %{ + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert [%{"slug" => "nfe-facil", "requests" => 3}] = Jason.decode!(text) + end + + test "analytics_visited reports stale" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/analytics/visited" + Req.Test.json(conn, %{"data" => [], "stale" => true}) + end) + + assert {:ok, text} = + Tools.call("analytics_visited", %{ + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert %{"stale" => true} = Jason.decode!(text) + end + + test "analytics_summary requires an app" do + assert {:error, message} = Tools.call("analytics_summary", %{}) + assert message =~ "app" + end + + test "analytics_summary forwards the range" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/apps/fagulha-app/analytics" + assert URI.decode_query(conn.query_string)["range"] == "90d" + + Req.Test.json(conn, %{"data" => %{"slug" => "fagulha-app", "range" => "90d"}}) + end) + + assert {:ok, text} = + Tools.call("analytics_summary", %{ + "app" => "fagulha-app", + "range" => "90d", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert %{"slug" => "fagulha-app", "range" => "90d"} = Jason.decode!(text) + end + test "signals_alerts acks when id is present" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "POST"