Current section
Files
Jump to
Current section
Files
lib/mix/tasks/relyra.trace.ex
defmodule Mix.Tasks.Relyra.Trace do
@moduledoc """
Prints redacted login traces for a connection (headless operator inspection).
Required flags: `--repo` and `--connection`. Optional `--last` (default 20).
mix relyra.trace --repo MyApp.Repo --connection CONNECTION_ID
mix relyra.trace --repo MyApp.Repo --connection CONNECTION_ID --last 5
"""
@shortdoc "Print redacted login traces for a connection."
use Mix.Task
alias Relyra.LiveAdmin.Query
alias Relyra.LiveAdmin.Scope
@impl true
def run(args) do
Mix.Task.run("app.start")
{opts, _argv, _invalid} =
OptionParser.parse(args,
strict: [repo: :string, connection: :string, last: :integer],
aliases: [r: :repo, c: :connection, n: :last]
)
repo_string =
Keyword.get(opts, :repo) ||
Mix.raise("--repo is required")
connection_id =
Keyword.get(opts, :connection) ||
Mix.raise("--connection is required")
limit = Keyword.get(opts, :last, 20)
repo =
try do
String.to_existing_atom(repo_string)
rescue
ArgumentError -> Mix.raise("Repo module #{repo_string} is not loaded")
end
scope = %Scope{actor: "system:relyra.trace", organization_id: nil}
case Query.get_login_traces(repo, scope, connection_id, limit: limit) do
{:ok, traces} ->
print_traces(traces, connection_id, limit)
:ok
{:error, error} ->
Mix.raise("relyra.trace failed: #{error.message}")
end
end
defp print_traces(traces, connection_id, limit) do
Mix.shell().info(
"relyra.trace: last #{length(traces)} login attempt(s) for connection #{connection_id} (limit #{limit})"
)
if traces == [] do
Mix.shell().info(
"No login attempts recorded yet — traces appear after the first SAML response is consumed."
)
else
Enum.each(traces, &print_trace/1)
end
end
defp print_trace(trace) do
Mix.shell().info("")
Mix.shell().info(
"[#{trace.inserted_at}] action=#{trace.action} correlation=#{trace.correlation_id || "—"}"
)
if trace.cause do
Mix.shell().info(" cause: #{trace.cause}")
end
Enum.each(trace.steps, fn step ->
step_name = step["step"] || "unknown"
outcome = step["outcome"] || "—"
error_code = step["error_code"]
duration = format_duration(step["duration_ms"])
line =
if error_code do
" #{step_name}: #{outcome} (#{duration}) error_code=#{error_code}"
else
" #{step_name}: #{outcome} (#{duration})"
end
Mix.shell().info(line)
end)
end
defp format_duration(nil), do: "—"
defp format_duration(ms) when is_integer(ms), do: "#{ms} ms"
defp format_duration(ms), do: to_string(ms)
end