diff --git a/README.md b/README.md index 1b6743f..646cbf2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ panel and is stored as a hash server-side. | `cleat apps update APP` | Edit repo / branch / auto-deploy / host / port / runtime | | `cleat apps logs APP` | App journal, with `--tail`, `--since`, `--grep`, `--follow` | | `cleat servers logs ID` | Host journal, with `--unit`, `--tail`, `--since`, `--grep`, `--follow` | +| `cleat events [APP]` | Search collected log events, with `--server`, `--unit`, `--query`, `--severity`, `--min-severity`, `--since`, `--until`, `--limit` | | `cleat env list APP` | List env vars (secrets masked) | | `cleat env set APP K=V` | Upsert one or more env vars | | `cleat env unset APP KEY` | Delete an env var | @@ -87,9 +88,10 @@ codex mcp add cleat -- cleat mcp grok mcp add cleat -- cleat mcp ``` -Tools: `whoami`, `servers_list`, `apps_list`, `apps_show`, `apps_create`, -`apps_update`, `apps_logs`, `env_list`, `env_set`, `env_unset`, `deploy`, -`deploy_status`, `deploy_logs`, `cancel_deploy`, `drop`, `init_project`. +Tools: `whoami`, `servers_list`, `server_logs`, `logs_search`, `apps_list`, +`apps_show`, `apps_create`, `apps_update`, `apps_logs`, `env_list`, `env_set`, +`env_unset`, `deploy`, `deploy_status`, `deploy_logs`, `cancel_deploy`, `drop`, +`init_project`. `deploy` queues and returns a `deployment_id`; follow it with `deploy_status` and `deploy_logs`. Credentials come from `cleat login` (or `CLEAT_PANEL_URL` / @@ -205,6 +207,32 @@ cleat servers logs 5 --unit caddy --since 30m `2026-09-21 14:30`). `--grep` is a case-sensitive substring filter. `--tail` defaults to 200 (max 5000). `--follow` keeps polling and prints new lines. +### Collected log events + +`apps logs` and `servers logs` read the live journal. `cleat events` searches +the panel's collected log store instead: events are enriched with tenant, app, +deploy, server and unit by the panel's collector, so they can be filtered by +app, severity and time window without touching the VM. + +```bash +# errors and worse for one app in the last hour +cleat events my-app --min-severity err --since 1h + +# free-text search across the tenant, newest first +cleat events --query "timeout" --limit 50 + +# exact level, specific unit, machine-readable +cleat events --app landing --severity warning --unit caddy --json + +# filter by release sha and cluster similar errors +cleat events my-app --release abc123 --group +``` + +Levels: `emerg`, `alert`, `crit`, `err`, `warning`, `notice`, `info`, `debug`. +`--min-severity` includes that level and above. The collector is opt-in on the +panel (`LOG_COLLECTOR_ENABLED=true`); if it is off, `cleat events` returns no +rows. + ### Environment variables Env vars are stored encrypted by the panel and written to the server diff --git a/lib/cleat/cli.ex b/lib/cleat/cli.ex index e83023e..f9216e7 100644 --- a/lib/cleat/cli.ex +++ b/lib/cleat/cli.ex @@ -11,6 +11,7 @@ defmodule Cleat.CLI do Deploy, Drop, Env, + Events, Init, Login, Logout, @@ -65,6 +66,14 @@ defmodule Cleat.CLI do since: :string, grep: :string, unit: :string, + query: :string, + severity: :string, + min_severity: :string, + until: :string, + limit: :integer, + release: :string, + environment: :string, + group: :boolean, release_name: :string, systemd_unit: :string, release_path: :string, @@ -81,7 +90,7 @@ defmodule Cleat.CLI do version: :boolean ] - @aliases [p: :panel, h: :help, v: :version] + @aliases [p: :panel, h: :help, v: :version, q: :query] def main(argv) do {opts, args, invalid} = OptionParser.parse(argv, strict: @switches, aliases: @aliases) @@ -114,6 +123,7 @@ defmodule Cleat.CLI do defp dispatch(["servers" | rest], opts), do: run(Servers.run(rest, opts)) defp dispatch(["apps" | rest], opts), do: run(Apps.run(rest, opts)) defp dispatch(["env" | rest], opts), do: run(Env.run(rest, opts)) + defp dispatch(["events" | rest], opts), do: run(Events.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)) @@ -196,6 +206,16 @@ defmodule Cleat.CLI do servers logs ID [--unit U] [--tail N] [--since S] [--grep T] [--follow] Host journal (all units, or one with --unit) + Observability + events [APP] [--server ID] [--unit U] \\ + [--query TEXT] [-q TEXT] \\ + [--severity LEVEL] [--min-severity LEVEL] \\ + [--since S] [--until S] [--limit N] \\ + [--release SHA] [--environment B] [--group] + Search collected log events + (levels: emerg alert crit err + warning notice info debug) + 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 88e87e8..290522d 100644 --- a/lib/cleat/client.ex +++ b/lib/cleat/client.ex @@ -65,6 +65,24 @@ defmodule Cleat.Client do request(client, :get, "/api/v1/servers/#{id}/logs", params: log_params(opts)) end + @doc """ + Searches collected log events (`GET /api/v1/logs`). + + `params` is a string-keyed map with the filters the panel understands: + `app`, `server`, `unit`, `q`, `severity`, `min_severity`, `since`, `until` + and `limit`. + """ + def search_logs(%__MODULE__{} = client, params) when is_map(params) do + request(client, :get, "/api/v1/logs", params: params) + end + + @doc """ + Groups similar collected errors (`GET /api/v1/logs/groups`). + """ + def search_log_groups(%__MODULE__{} = client, params) when is_map(params) do + request(client, :get, "/api/v1/logs/groups", params: params) + end + defp log_params(opts) do %{ "tail" => opts[:tail], diff --git a/lib/cleat/commands/events.ex b/lib/cleat/commands/events.ex new file mode 100644 index 0000000..202dcb4 --- /dev/null +++ b/lib/cleat/commands/events.ex @@ -0,0 +1,121 @@ +defmodule Cleat.Commands.Events do + @moduledoc """ + `cleat events` — searches the panel's collected log events. + + This is the stored, correlatable view of logs (observability Corte 01): + events are enriched by the panel with tenant, app, server, deployment and + unit, so they can be filtered by app, severity, unit and time window without + touching the VM. + """ + + alias Cleat.{Client, Commands, Output} + + @usage "usage: cleat events [APP] [--server ID] [--unit U] [--query TEXT] [--severity LEVEL] [--min-severity LEVEL] [--since S] [--until S] [--limit N] [--release SHA] [--environment B] [--group] [--json]" + + @severities ~w(emerg alert crit err warning notice info debug) + + def run(args, opts) do + with {:ok, params} <- params(args, opts), + {:ok, client} <- Commands.client(opts), + {:ok, body} <- fetch(client, params, opts) do + rows = Commands.data(body) + + cond do + opts[:json] -> Output.json(rows) + opts[:group] -> print_groups(rows) + true -> print(rows) + end + + :ok + end + end + + defp params(args, opts) do + with {:ok, app} <- app(args, opts), + {:ok, severity} <- severity(opts[:severity], "--severity"), + {:ok, min_severity} <- severity(opts[:min_severity], "--min-severity") do + params = %{ + "app" => app, + "server" => opts[:server], + "unit" => opts[:unit], + "q" => opts[:query], + "severity" => severity, + "min_severity" => min_severity, + "since" => opts[:since], + "until" => opts[:until], + "limit" => opts[:limit], + "release" => opts[:release], + "environment" => opts[:environment] + } + + {:ok, params |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) |> Map.new()} + end + end + + defp app([], opts), do: {:ok, opts[:app]} + defp app([app], _opts) when is_binary(app), do: {:ok, app} + defp app(_args, _opts), do: {:error, @usage} + + defp severity(value, _flag) when value in [nil, ""], do: {:ok, nil} + + defp severity(value, flag) do + if value in @severities do + {:ok, value} + else + {:error, "invalid #{flag} (use #{Enum.join(@severities, ", ")})"} + end + end + + defp fetch(client, params, opts) do + if opts[:group] do + Client.search_log_groups(client, params) + else + Client.search_logs(client, params) + end + end + + defp print([]), do: Output.info("No log events matched.") + + defp print(events) do + rows = + Enum.map(events, fn event -> + [ + timestamp(event["occurred_at"]), + event["severity"], + event["app_id"] || "—", + event["unit"] || "—", + message(event["message"]) + ] + end) + + Output.table(rows, ["TIME", "SEV", "APP", "UNIT", "MESSAGE"]) + end + + defp print_groups([]), do: Output.info("No error groups matched.") + + defp print_groups(groups) do + rows = + Enum.map(groups, fn group -> + [ + group["count"], + group["severity"], + group["last_seen_at"] || "—", + message(group["sample"]) + ] + end) + + Output.table(rows, ["COUNT", "SEV", "LAST", "SAMPLE"]) + end + + defp timestamp(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> Output.datetime(datetime) + _ -> value + end + end + + defp timestamp(_), do: "—" + + defp message(nil), do: "" + defp message(text), do: text |> String.replace("\n", " ") |> String.slice(0, 120) +end diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index ac6c67b..3cd0c9d 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -274,6 +274,65 @@ defmodule Cleat.MCP.Tools do end) end }, + %{ + "name" => "logs_search", + "description" => + "Search collected log events by app, server, unit, text, severity, release, environment and time window. Set group=true for clustered errors.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "app" => @app, + "server" => %{"type" => "string", "description" => "Server id"}, + "unit" => %{"type" => "string"}, + "q" => %{"type" => "string", "description" => "Case-insensitive substring"}, + "severity" => %{ + "type" => "string", + "description" => "Exact level: emerg alert crit err warning notice info debug" + }, + "min_severity" => %{ + "type" => "string", + "description" => "Include this level and above" + }, + "release" => %{"type" => "string", "description" => "Deployment id or git sha prefix"}, + "environment" => %{"type" => "string", "description" => "App branch / environment"}, + "group" => %{"type" => "boolean", "description" => "Cluster similar errors"}, + "since" => %{"type" => "string", "description" => "30m, 2h, 1d or ISO time"}, + "until" => %{"type" => "string", "description" => "ISO time"}, + "limit" => %{"type" => "integer"}, + "panel" => @panel, + "token" => @token + } + }, + "handler" => fn args -> + params = + %{ + "app" => args["app"], + "server" => args["server"], + "unit" => args["unit"], + "q" => args["q"], + "severity" => args["severity"], + "min_severity" => args["min_severity"], + "release" => args["release"], + "environment" => args["environment"], + "since" => args["since"], + "until" => args["until"], + "limit" => args["limit"] + } + |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end) + |> Map.new() + + with_client(args, fn client -> + result = + if args["group"] do + Client.search_log_groups(client, params) + else + Client.search_logs(client, params) + end + + with {:ok, body} <- result, do: {:ok, data_text(body)} + end) + end + }, %{ "name" => "apps_list", "description" => "List apps", diff --git a/test/cleat/client_test.exs b/test/cleat/client_test.exs index 2f82043..3ebb47a 100644 --- a/test/cleat/client_test.exs +++ b/test/cleat/client_test.exs @@ -9,6 +9,25 @@ defmodule Cleat.ClientTest do :ok end + test "search_logs sends the filters to /api/v1/logs" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/logs" + + assert URI.decode_query(conn.query_string) == %{ + "app" => "my-app", + "min_severity" => "err" + } + + Req.Test.json(conn, %{"data" => [%{"id" => 1, "message" => "boom"}]}) + end) + + client = Client.new("https://panel.test", "tok") + + assert {:ok, %{"data" => [%{"id" => 1, "message" => "boom"}]}} = + Client.search_logs(client, %{"app" => "my-app", "min_severity" => "err"}) + end + test "list_servers returns the panel payload" do Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, %{"data" => [%{"id" => 1, "name" => "srv"}]}) diff --git a/test/cleat/commands/events_test.exs b/test/cleat/commands/events_test.exs new file mode 100644 index 0000000..705db18 --- /dev/null +++ b/test/cleat/commands/events_test.exs @@ -0,0 +1,96 @@ +defmodule Cleat.Commands.EventsTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias Cleat.Commands.Events + + @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 "prints collected events as a table" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/logs" + + Req.Test.json(conn, %{"data" => [event("err", "boom happened")]}) + end) + + output = capture_io(fn -> assert :ok = Events.run([], @conn) end) + assert output =~ "boom happened" + assert output =~ "err" + assert output =~ "phx-app.service" + end + + test "passes the positional app and the search filters" do + Req.Test.stub(__MODULE__, fn conn -> + params = URI.decode_query(conn.query_string) + assert params["app"] == "my-app" + assert params["min_severity"] == "warning" + assert params["q"] == "timeout" + assert params["limit"] == "50" + + Req.Test.json(conn, %{"data" => []}) + end) + + assert :ok = + Events.run( + ["my-app"], + @conn + |> Map.put(:min_severity, "warning") + |> Map.put(:query, "timeout") + |> Map.put(:limit, 50) + ) + end + + test "reports when nothing matches" do + Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, %{"data" => []}) end) + + output = capture_io(fn -> assert :ok = Events.run([], @conn) end) + assert output =~ "No log events matched" + end + + test "rejects an unknown severity" do + assert {:error, message} = Events.run([], Map.put(@conn, :severity, "nope")) + assert message =~ "invalid --severity" + end + + test "group mode hits /logs/groups" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.request_path == "/api/v1/logs/groups" + Req.Test.json(conn, %{"data" => [%{"count" => 3, "severity" => "err", "sample" => "boom"}]}) + end) + + output = capture_io(fn -> assert :ok = Events.run([], Map.put(@conn, :group, true)) end) + assert output =~ "boom" + assert output =~ "err" + end + + test "json mode prints the raw events" do + Req.Test.stub(__MODULE__, fn conn -> + Req.Test.json(conn, %{"data" => [event("info", "hi")]}) + end) + + output = capture_io(fn -> assert :ok = Events.run([], Map.put(@conn, :json, true)) end) + assert output =~ ~s("message": "hi") + end + + defp event(severity, message) do + %{ + "id" => 1, + "app_id" => 7, + "server_id" => 5, + "deployment_id" => nil, + "unit" => "phx-app.service", + "source" => "app", + "severity" => severity, + "message" => message, + "occurred_at" => "2026-09-28T12:00:00Z" + } + end +end diff --git a/test/cleat/contract_test.exs b/test/cleat/contract_test.exs index e8a177d..cd9f3a9 100644 --- a/test/cleat/contract_test.exs +++ b/test/cleat/contract_test.exs @@ -23,6 +23,9 @@ defmodule Cleat.ContractTest do "deployment_log" => ~w(id app_id git_sha git_ref status triggered_by started_at finished_at inserted_at updated_at wait_reason log), "env_var" => ~w(key value branch sensitive revealed), + "log_event" => + ~w(id app_id server_id deployment_id unit source severity message environment fingerprint occurred_at), + "log_group" => ~w(fingerprint severity count sample last_seen_at), "user" => ~w(id email), "tenant" => ~w(id name slug), "me" => ~w(user tenant role), diff --git a/test/cleat/mcp/tools_test.exs b/test/cleat/mcp/tools_test.exs index 080d6ed..e8ef788 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -32,6 +32,7 @@ defmodule Cleat.MCP.ToolsTest do "whoami", "servers_list", "server_logs", + "logs_search", "apps_list", "apps_show", "apps_create", @@ -136,6 +137,33 @@ defmodule Cleat.MCP.ToolsTest do assert Jason.decode!(text)["lines"] == ["ERROR boom"] end + test "logs_search forwards the filters to /api/v1/logs" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/logs" + + params = URI.decode_query(conn.query_string) + assert params["app"] == "landing" + assert params["min_severity"] == "err" + assert params["q"] == "timeout" + assert params["limit"] == "10" + + Req.Test.json(conn, %{"data" => [%{"id" => 1, "message" => "boom"}]}) + end) + + assert {:ok, text} = + Tools.call("logs_search", %{ + "app" => "landing", + "min_severity" => "err", + "q" => "timeout", + "limit" => 10, + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert [%{"message" => "boom"}] = Jason.decode!(text) + end + test "server_logs GETs /api/v1/servers/5/logs with the unit filter" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "GET" diff --git a/test/fixtures/api_contract.json b/test/fixtures/api_contract.json index 47f7738..d728e47 100644 --- a/test/fixtures/api_contract.json +++ b/test/fixtures/api_contract.json @@ -54,6 +54,26 @@ "sensitive", "revealed" ], + "log_event": [ + "id", + "app_id", + "server_id", + "deployment_id", + "unit", + "source", + "severity", + "message", + "environment", + "fingerprint", + "occurred_at" + ], + "log_group": [ + "fingerprint", + "severity", + "count", + "sample", + "last_seen_at" + ], "me": [ "user", "tenant",