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
6 changes: 6 additions & 0 deletions lib/cleat/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions lib/cleat/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 1 addition & 1 deletion lib/cleat/commands/completions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
100 changes: 99 additions & 1 deletion lib/cleat/commands/signals.ex
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
57 changes: 57 additions & 0 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions test/cleat/cli_mcp_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions test/cleat/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]})
Expand Down
Loading
Loading