diff --git a/lib/cleat/cli.ex b/lib/cleat/cli.ex index d39388b..152259a 100644 --- a/lib/cleat/cli.ex +++ b/lib/cleat/cli.ex @@ -76,6 +76,9 @@ defmodule Cleat.CLI do environment: :string, group: :boolean, range: :string, + trace_id: :string, + service: :string, + rate: :float, release_name: :string, systemd_unit: :string, release_path: :string, @@ -223,6 +226,9 @@ defmodule Cleat.CLI do RED metrics and deploy markers signals alerts [list] Open default alerts signals alerts ack ID Confirm a firing alert + signals traces APP [--trace-id ID] [--service NAME] + List traces or open a waterfall + signals sampling APP [--rate 0..1] Read or set trace sampling Environment env list APP [--branch B] [--reveal] List env vars (secrets masked) diff --git a/lib/cleat/client.ex b/lib/cleat/client.ex index c6992c7..1d4e272 100644 --- a/lib/cleat/client.ex +++ b/lib/cleat/client.ex @@ -101,6 +101,21 @@ defmodule Cleat.Client do request(client, :post, "/api/v1/signals/alerts/#{id}/ack", json: %{}) end + @doc "List traces or fetch a waterfall (`GET /api/v1/signals/traces`)." + def signals_traces(%__MODULE__{} = client, params) when is_map(params) do + request(client, :get, "/api/v1/signals/traces", params: params) + end + + @doc "Read per-app trace sample rate (`GET /api/v1/signals/sampling`)." + def signals_sampling(%__MODULE__{} = client, params) when is_map(params) do + request(client, :get, "/api/v1/signals/sampling", params: params) + end + + @doc "Set per-app trace sample rate (`PATCH /api/v1/signals/sampling`)." + def update_signals_sampling(%__MODULE__{} = client, params) when is_map(params) do + request(client, :patch, "/api/v1/signals/sampling", json: params) + end + defp log_params(opts) do %{ "tail" => opts[:tail], diff --git a/lib/cleat/commands/completions.ex b/lib/cleat/commands/completions.ex index 310b663..20bccd8 100644 --- a/lib/cleat/commands/completions.ex +++ b/lib/cleat/commands/completions.ex @@ -13,7 +13,7 @@ defmodule Cleat.Commands.Completions do "apps" => ~w(list show create update delete logs), "env" => ~w(list set unset), "config" => ~w(list get set unset), - "signals" => ~w(health metrics alerts) + "signals" => ~w(health metrics alerts traces sampling) } def run(["bash"], _opts), do: print(bash()) diff --git a/lib/cleat/commands/signals.ex b/lib/cleat/commands/signals.ex index 3f789b0..f744ff5 100644 --- a/lib/cleat/commands/signals.ex +++ b/lib/cleat/commands/signals.ex @@ -1,6 +1,6 @@ defmodule Cleat.Commands.Signals do @moduledoc """ - `cleat signals` — health, metrics and alerts from the panel (Corte 02). + `cleat signals` — health, metrics, alerts and traces from the panel. """ alias Cleat.{Client, Commands, Output} @@ -11,6 +11,8 @@ defmodule Cleat.Commands.Signals do cleat signals metrics APP [--range 1h|6h|24h|1d] cleat signals alerts [list] cleat signals alerts ack ID + cleat signals traces APP [--trace-id ID] [--service NAME] + cleat signals sampling APP [--rate 0.0..1.0] """ def run(["health" | rest], opts), do: health(rest, opts) @@ -19,6 +21,10 @@ defmodule Cleat.Commands.Signals do def run(["alerts"], opts), do: alerts(opts) def run(["alerts", "list" | _rest], opts), do: alerts(opts) def run(["alerts", "ack", id | _rest], opts), do: ack(id, opts) + def run(["traces", app | _rest], opts), do: traces(app, opts) + def run(["traces"], _opts), do: {:error, @usage} + def run(["sampling", app | _rest], opts), do: sampling(app, opts) + def run(["sampling"], _opts), do: {:error, @usage} def run(_args, _opts), do: {:error, @usage} defp health(args, opts) do @@ -66,6 +72,41 @@ defmodule Cleat.Commands.Signals do end end + defp traces(app, opts) do + params = + compact(%{ + "app" => app, + "trace_id" => opts[:trace_id], + "service" => opts[:service] + }) + + with {:ok, client} <- Commands.client(opts), + {:ok, body} <- Client.signals_traces(client, params) do + data = Commands.data(body) + if opts[:json], do: Output.json(data), else: print_traces(data) + :ok + end + end + + defp sampling(app, opts) do + with {:ok, client} <- Commands.client(opts) do + result = + case opts[:rate] do + nil -> + Client.signals_sampling(client, compact(%{"app" => app})) + + rate -> + Client.update_signals_sampling(client, %{"app" => app, "rate" => rate}) + end + + with {:ok, body} <- result do + data = Commands.data(body) + if opts[:json], do: Output.json(data), else: print_sampling(data) + :ok + end + end + end + defp optional_app([], opts), do: {:ok, opts[:app]} defp optional_app([app], _opts) when is_binary(app), do: {:ok, app} defp optional_app(_args, _opts), do: {:error, @usage} @@ -140,4 +181,61 @@ defmodule Cleat.Commands.Signals do defp markers_label(markers) do Enum.map_join(markers, ",", fn marker -> marker["git_sha"] || inspect(marker) end) end + + defp print_traces(rows) when is_list(rows) do + if rows == [] do + Output.info("No traces in this window.") + else + table = + Enum.map(rows, fn row -> + [ + row["trace_id"], + row["root_name"], + services(row["services"]), + row["duration_ms"], + row["span_count"], + if(row["error"], do: "error", else: "ok") + ] + end) + + Output.table(table, ["TRACE", "ROOT", "SERVICES", "MS", "SPANS", "STATUS"]) + end + end + + defp print_traces(%{} = detail) do + trace = detail["trace"] || %{} + Output.info("#{trace["trace_id"]} #{trace["root_name"]} #{trace["duration_ms"]}ms") + + Enum.each(detail["spans"] || [], fn span -> + indent = String.duplicate(" ", span["depth"] || 0) + Output.info("#{indent}#{span["name"]} #{span["service_name"]} #{span["duration_ms"]}ms") + end) + + print_service_map(detail["service_map"]) + print_trace_logs(detail["logs"] || []) + end + + defp print_service_map(%{"edges" => edges}) when is_list(edges) and edges != [] do + Output.info("MAP #{Enum.map_join(edges, ",", &edge_label/1)}") + end + + defp print_service_map(_), do: :ok + + defp print_trace_logs([]), do: :ok + + defp print_trace_logs(logs) do + Enum.each(logs, fn log -> + Output.info("LOG #{log["id"]} #{log["message"]}") + end) + end + + defp print_sampling(data) do + Output.info("#{data["slug"]} rate=#{data["trace_sample_rate"]}") + end + + defp services(list) when is_list(list), do: Enum.join(list, ",") + defp services(_), do: "—" + + defp edge_label(%{"from" => from, "to" => to, "count" => count}), do: "#{from}→#{to}(#{count})" + defp edge_label(other), do: inspect(other) end diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index 1b8c2e0..bab736e 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -458,6 +458,63 @@ defmodule Cleat.MCP.Tools do end) end }, + %{ + "name" => "signals_traces", + "description" => + "List traces for an app, or fetch waterfall, service map and correlated logs when trace_id is set.", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "app" => @app, + "trace_id" => %{"type" => "string", "description" => "Open one trace waterfall"}, + "service" => %{"type" => "string", "description" => "Filter traces by service name"}, + "panel" => @panel, + "token" => @token + }, + "required" => ["app"] + }, + "handler" => fn args -> + params = + compact_params(%{ + "app" => args["app"], + "trace_id" => args["trace_id"], + "service" => args["service"] + }) + + with_client(args, fn client -> + with {:ok, body} <- Client.signals_traces(client, params), do: {:ok, data_text(body)} + end) + end + }, + %{ + "name" => "signals_set_sampling", + "description" => + "Set the fraction of traces stored for an app (0 disables ingest, 1 stores all).", + "inputSchema" => %{ + "type" => "object", + "properties" => %{ + "app" => @app, + "rate" => %{ + "type" => "number", + "description" => "Sample rate between 0 and 1" + }, + "panel" => @panel, + "token" => @token + }, + "required" => ["app", "rate"] + }, + "handler" => fn args -> + with_client(args, fn client -> + with {:ok, body} <- + Client.update_signals_sampling(client, %{ + "app" => args["app"], + "rate" => args["rate"] + }) 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 a55e885..646bed9 100644 --- a/test/cleat/cli_mcp_test.exs +++ b/test/cleat/cli_mcp_test.exs @@ -23,4 +23,10 @@ defmodule Cleat.CLIMcpTest do assert usage =~ "signals metrics" assert usage =~ "signals alerts" end + + test "usage documents signals traces and sampling" do + usage = CLI.usage() + assert usage =~ "signals traces" + assert usage =~ "signals sampling" + end end diff --git a/test/cleat/client_test.exs b/test/cleat/client_test.exs index 45779b4..4870e49 100644 --- a/test/cleat/client_test.exs +++ b/test/cleat/client_test.exs @@ -42,6 +42,38 @@ defmodule Cleat.ClientTest do Client.signals_health(client, %{"app" => "catalogo"}) end + test "signals_traces GETs /api/v1/signals/traces" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/traces" + params = URI.decode_query(conn.query_string) + assert params["app"] == "catalogo" + assert params["service"] == "payment" + Req.Test.json(conn, %{"data" => [%{"trace_id" => "abc"}]}) + end) + + client = Client.new("https://panel.test", "tok") + + assert {:ok, %{"data" => [%{"trace_id" => "abc"}]}} = + Client.signals_traces(client, %{"app" => "catalogo", "service" => "payment"}) + end + + test "update_signals_sampling PATCHes /api/v1/signals/sampling" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "PATCH" + assert conn.request_path == "/api/v1/signals/sampling" + body = conn |> Req.Test.raw_body() |> Jason.decode!() + assert body["app"] == "catalogo" + assert body["rate"] == 0.25 + Req.Test.json(conn, %{"data" => %{"trace_sample_rate" => 0.25}}) + end) + + client = Client.new("https://panel.test", "tok") + + assert {:ok, %{"data" => %{"trace_sample_rate" => 0.25}}} = + Client.update_signals_sampling(client, %{"app" => "catalogo", "rate" => 0.25}) + 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/signals_test.exs b/test/cleat/commands/signals_test.exs index bc59566..f33d0ab 100644 --- a/test/cleat/commands/signals_test.exs +++ b/test/cleat/commands/signals_test.exs @@ -139,6 +139,147 @@ defmodule Cleat.Commands.SignalsTest do assert output =~ ~s("slug": "catalogo") end + test "traces lists traces for an app" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/traces" + params = URI.decode_query(conn.query_string) + assert params["app"] == "catalogo" + refute Map.has_key?(params, "trace_id") + + Req.Test.json(conn, %{ + "data" => [ + %{ + "trace_id" => "5b8aa5a2d2c872e8321cf37308d69df2", + "root_name" => "GET /checkout", + "services" => ["shop", "payment"], + "started_at" => "2026-10-01T21:00:00Z", + "duration_ms" => 42, + "span_count" => 2, + "error" => false + } + ] + }) + end) + + output = capture_io(fn -> assert :ok = Signals.run(["traces", "catalogo"], @conn) end) + assert output =~ "5b8aa5a2d2c872e8321cf37308d69df2" + assert output =~ "GET /checkout" + assert output =~ "shop" + end + + test "traces forwards --service and --trace-id" do + Req.Test.stub(__MODULE__, fn conn -> + params = URI.decode_query(conn.query_string) + assert params["app"] == "catalogo" + assert params["service"] == "payment" + assert params["trace_id"] == "5b8aa5a2d2c872e8321cf37308d69df2" + + Req.Test.json(conn, %{ + "data" => %{ + "trace" => %{ + "trace_id" => "5b8aa5a2d2c872e8321cf37308d69df2", + "root_name" => "GET /checkout", + "services" => ["shop", "payment"], + "duration_ms" => 42, + "span_count" => 2, + "error" => false + }, + "spans" => [ + %{ + "span_id" => "051581bf3cb55c13", + "parent_span_id" => nil, + "name" => "GET /checkout", + "service_name" => "shop", + "duration_ms" => 42, + "depth" => 0, + "status_code" => "ok" + }, + %{ + "span_id" => "5fb8a98c0bec6479", + "parent_span_id" => "051581bf3cb55c13", + "name" => "charge", + "service_name" => "payment", + "duration_ms" => 12, + "depth" => 1, + "status_code" => "ok" + } + ], + "service_map" => %{ + "nodes" => ["shop", "payment"], + "edges" => [%{"from" => "shop", "to" => "payment", "count" => 1}] + }, + "logs" => [%{"id" => 9, "message" => "trace 5b8aa5a2d2c872e8321cf37308d69df2"}] + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + Signals.run( + ["traces", "catalogo"], + Map.merge(@conn, %{ + service: "payment", + trace_id: "5b8aa5a2d2c872e8321cf37308d69df2" + }) + ) + end) + + assert output =~ "GET /checkout" + assert output =~ "charge" + assert output =~ "shop" + assert output =~ "payment" + assert output =~ "5b8aa5a2d2c872e8321cf37308d69df2" + end + + test "traces without an app returns usage" do + assert {:error, message} = Signals.run(["traces"], @conn) + assert message =~ "usage" + end + + test "sampling reads the current rate" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/sampling" + assert URI.decode_query(conn.query_string)["app"] == "catalogo" + + Req.Test.json(conn, %{ + "data" => %{"app_id" => 6, "slug" => "catalogo", "trace_sample_rate" => 0.0} + }) + end) + + output = capture_io(fn -> assert :ok = Signals.run(["sampling", "catalogo"], @conn) end) + assert output =~ "catalogo" + assert output =~ "0.0" or output =~ "0" + end + + test "sampling --rate patches the panel" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "PATCH" + assert conn.request_path == "/api/v1/signals/sampling" + body = conn |> Req.Test.raw_body() |> Jason.decode!() + assert body["app"] == "catalogo" + assert body["rate"] == 0.5 + + Req.Test.json(conn, %{ + "data" => %{"app_id" => 6, "slug" => "catalogo", "trace_sample_rate" => 0.5} + }) + end) + + output = + capture_io(fn -> + assert :ok = Signals.run(["sampling", "catalogo"], Map.put(@conn, :rate, 0.5)) + end) + + assert output =~ "0.5" + end + + test "sampling without an app returns usage" do + assert {:error, message} = Signals.run(["sampling"], @conn) + assert message =~ "usage" + end + test "unknown subcommand returns usage" do assert {:error, message} = Signals.run(["nope"], @conn) assert message =~ "usage" diff --git a/test/cleat/contract_test.exs b/test/cleat/contract_test.exs index c5f93e4..9fc31aa 100644 --- a/test/cleat/contract_test.exs +++ b/test/cleat/contract_test.exs @@ -32,6 +32,12 @@ defmodule Cleat.ContractTest do "signal_alert" => ~w(id app_id slug rule status message channel fired_at acked_at delivered_at), "signal_incident" => ~w(app_id slug events), + "signal_trace" => ~w(trace_id root_name services started_at duration_ms span_count error), + "signal_span" => + ~w(trace_id span_id parent_span_id name kind service_name status_code start_time_unix_nano duration_ms depth attributes), + "signal_service_map" => ~w(nodes edges), + "signal_sampling" => ~w(app_id slug trace_sample_rate), + "signal_trace_detail" => ~w(trace spans service_map logs), "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 2e8c586..72a9ab8 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -36,6 +36,8 @@ defmodule Cleat.MCP.ToolsTest do "signals_health", "signals_metrics", "signals_alerts", + "signals_traces", + "signals_set_sampling", "apps_list", "apps_show", "apps_create", @@ -208,6 +210,55 @@ defmodule Cleat.MCP.ToolsTest do assert %{"status" => "acked"} = Jason.decode!(text) end + test "signals_traces GETs /api/v1/signals/traces" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/signals/traces" + params = URI.decode_query(conn.query_string) + assert params["app"] == "catalogo" + assert params["trace_id"] == "abc" + + Req.Test.json(conn, %{"data" => %{"trace" => %{"trace_id" => "abc"}}}) + end) + + assert {:ok, text} = + Tools.call("signals_traces", %{ + "app" => "catalogo", + "trace_id" => "abc", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert %{"trace" => %{"trace_id" => "abc"}} = Jason.decode!(text) + end + + test "signals_set_sampling requires an app" do + assert {:error, message} = Tools.call("signals_set_sampling", %{}) + assert message =~ "app" + end + + test "signals_set_sampling PATCHes the rate" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "PATCH" + assert conn.request_path == "/api/v1/signals/sampling" + body = conn |> Req.Test.raw_body() |> Jason.decode!() + assert body["app"] == "catalogo" + assert body["rate"] == 1 + + Req.Test.json(conn, %{"data" => %{"slug" => "catalogo", "trace_sample_rate" => 1.0}}) + end) + + assert {:ok, text} = + Tools.call("signals_set_sampling", %{ + "app" => "catalogo", + "rate" => 1, + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert %{"trace_sample_rate" => 1.0} = 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 d0f311f..81aa53b 100644 --- a/test/fixtures/api_contract.json +++ b/test/fixtures/api_contract.json @@ -110,6 +110,43 @@ "slug", "events" ], + "signal_trace": [ + "trace_id", + "root_name", + "services", + "started_at", + "duration_ms", + "span_count", + "error" + ], + "signal_span": [ + "trace_id", + "span_id", + "parent_span_id", + "name", + "kind", + "service_name", + "status_code", + "start_time_unix_nano", + "duration_ms", + "depth", + "attributes" + ], + "signal_service_map": [ + "nodes", + "edges" + ], + "signal_sampling": [ + "app_id", + "slug", + "trace_sample_rate" + ], + "signal_trace_detail": [ + "trace", + "spans", + "service_map", + "logs" + ], "me": [ "user", "tenant",