|
| 1 | +defmodule ObserverWeb.Crashdump do |
| 2 | + @moduledoc """ |
| 3 | + Browses `erl_crash.dump` files in the web dashboard - the web equivalent of OTP's |
| 4 | + `crashdump_viewer` GUI, reusing its battle-tested parser (`:crashdump_viewer`, part of the |
| 5 | + `:observer` application) instead of reimplementing the dump format. |
| 6 | +
|
| 7 | + ## Requirements |
| 8 | +
|
| 9 | + Parsing needs OTP's `:crashdump_viewer` (part of the `:observer` application, which ships with |
| 10 | + OTP but is commonly excluded from releases). No GUI/wx is ever started; only the parsing |
| 11 | + `gen_server` is used. When `:observer` isn't in the release, the page explains what to add. |
| 12 | +
|
| 13 | + ## Ingesting a dump |
| 14 | +
|
| 15 | + Two ways: |
| 16 | +
|
| 17 | + * **Upload** an `erl_crash.dump` straight from the browser (e.g. one pulled off a crashed |
| 18 | + Nerves device) - it's parsed on the dashboard node and the temporary copy is discarded. |
| 19 | + * **Browse** dumps already present on the dashboard host, from directories the host |
| 20 | + explicitly allowlists (never a free path): |
| 21 | +
|
| 22 | + config :observer_web, crashdump_dirs: ["/var/log/my_app/crashdumps"] |
| 23 | +
|
| 24 | + ## Security |
| 25 | +
|
| 26 | + Crash dumps contain everything the VM held at crash time: process states, message queues, |
| 27 | + application data. Directory browsing is limited to the allowlisted directories, uploads are |
| 28 | + size-capped, and dumps are parsed on the dashboard node only - never fetched from remote nodes. |
| 29 | + """ |
| 30 | + |
| 31 | + alias ObserverWeb.Crashdump.Server |
| 32 | + |
| 33 | + @doc """ |
| 34 | + Whether the `:crashdump_viewer` parser is available on this node. |
| 35 | + """ |
| 36 | + @spec available? :: boolean() |
| 37 | + def available?, do: Code.ensure_loaded?(:crashdump_viewer) |
| 38 | + |
| 39 | + @doc """ |
| 40 | + Lists the crash dump files found in the configured `:crashdump_dirs` (newest first). |
| 41 | + Returns `{:error, :not_configured}` when no directory is configured - the feature's off state. |
| 42 | + """ |
| 43 | + @spec list_dumps() :: {:ok, [map()]} | {:error, :not_configured} |
| 44 | + def list_dumps do |
| 45 | + case Application.get_env(:observer_web, :crashdump_dirs, []) do |
| 46 | + [] -> |
| 47 | + {:error, :not_configured} |
| 48 | + |
| 49 | + dirs when is_list(dirs) -> |
| 50 | + dumps = |
| 51 | + dirs |
| 52 | + |> Enum.flat_map(&list_dir/1) |
| 53 | + |> Enum.sort_by(& &1.mtime, :desc) |
| 54 | + |
| 55 | + {:ok, dumps} |
| 56 | + end |
| 57 | + end |
| 58 | + |
| 59 | + defp list_dir(dir) do |
| 60 | + dir |
| 61 | + |> Path.join("*.dump") |
| 62 | + |> Path.wildcard() |
| 63 | + |> Kernel.++(Path.wildcard(Path.join(dir, "erl_crash.dump"))) |
| 64 | + |> Enum.uniq() |
| 65 | + |> Enum.flat_map(fn path -> |
| 66 | + case File.stat(path, time: :posix) do |
| 67 | + {:ok, %File.Stat{type: :regular, size: size, mtime: mtime}} -> |
| 68 | + [%{path: path, name: Path.basename(path), size: size, mtime: mtime}] |
| 69 | + |
| 70 | + _unreadable -> |
| 71 | + [] |
| 72 | + end |
| 73 | + end) |
| 74 | + end |
| 75 | + |
| 76 | + @doc """ |
| 77 | + Starts loading a dump. Progress and completion are broadcast on the `"crashdump"` PubSub |
| 78 | + topic as `{:crashdump_progress, percent | {:error, reason} | :done}` - subscribe via |
| 79 | + `subscribe/0`. Only paths returned by `list_dumps/0` are accepted. |
| 80 | + """ |
| 81 | + @spec load(String.t()) :: :ok | {:error, term()} |
| 82 | + def load(path) do |
| 83 | + with :ok <- check_available(), |
| 84 | + {:ok, dumps} <- list_dumps(), |
| 85 | + %{path: ^path} <- Enum.find(dumps, &(&1.path == path)) || {:error, :unknown_dump} do |
| 86 | + Server.load(path) |
| 87 | + end |
| 88 | + end |
| 89 | + |
| 90 | + @doc """ |
| 91 | + Loads a dump from an uploaded file's already-consumed temporary path. Unlike `load/1` this |
| 92 | + skips the directory allowlist: the path isn't user-supplied text but a temp file LiveView |
| 93 | + produced from a size-capped upload (see `Observer.Web.Crashdump.Page`). |
| 94 | + """ |
| 95 | + @spec load_upload(String.t()) :: :ok | {:error, term()} |
| 96 | + def load_upload(path) do |
| 97 | + with :ok <- check_available() do |
| 98 | + Server.load(path) |
| 99 | + end |
| 100 | + end |
| 101 | + |
| 102 | + defp check_available do |
| 103 | + if available?(), do: :ok, else: {:error, :crashdump_viewer_unavailable} |
| 104 | + end |
| 105 | + |
| 106 | + @doc """ |
| 107 | + The server's current state: `:idle`, `{:loading, path, percent}` or `{:loaded, path}`. |
| 108 | + """ |
| 109 | + @spec status() :: :idle | {:loading, String.t(), non_neg_integer()} | {:loaded, String.t()} |
| 110 | + defdelegate status, to: Server |
| 111 | + |
| 112 | + @doc """ |
| 113 | + Subscribes the caller to load progress broadcasts. |
| 114 | + """ |
| 115 | + @spec subscribe() :: :ok | {:error, term()} |
| 116 | + def subscribe do |
| 117 | + Phoenix.PubSub.subscribe(ObserverWeb.PubSub, "crashdump") |
| 118 | + end |
| 119 | + |
| 120 | + @doc """ |
| 121 | + General information about the loaded dump. |
| 122 | + """ |
| 123 | + @spec general_info() :: {:ok, map()} | {:error, :no_dump_loaded} |
| 124 | + defdelegate general_info, to: Server |
| 125 | + |
| 126 | + @doc """ |
| 127 | + The loaded dump's process summaries (pid, name, state, current function, message queue |
| 128 | + length, reductions, memory) - the integer fields are sortable. |
| 129 | + """ |
| 130 | + @spec processes() :: {:ok, [map()]} | {:error, :no_dump_loaded} |
| 131 | + defdelegate processes, to: Server |
| 132 | + |
| 133 | + @doc """ |
| 134 | + Full details of one process in the loaded dump, by its pid string (e.g. `"<0.42.0>"`), |
| 135 | + including the stack dump and message queue as expandable strings. |
| 136 | + """ |
| 137 | + @spec proc_details(String.t()) :: {:ok, map()} | {:error, :no_dump_loaded | :not_found} |
| 138 | + defdelegate proc_details(pid_string), to: Server |
| 139 | +end |
0 commit comments