Packages
mob_dev
0.6.21
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/bench/logger.ex
defmodule MobDev.Bench.Logger do
@moduledoc """
Append-only CSV log of bench probe snapshots.
Format:
ts_ms,elapsed_sec,reachability,app_process,usb,screen,battery_pct,reason
Reading:
- `ts_ms` is monotonic — safe to subtract for intervals
- `elapsed_sec` is seconds since the run started (set on `open/2`)
- `reachability`, `app_process`, `usb`, `screen` are atoms (string-encoded)
- `battery_pct` is integer or empty
- `reason` is a string (CSV-escaped)
Use `summary/1` after a run to compute % success, gap distribution,
reconnect count, etc.
"""
alias MobDev.Bench.Probe
defstruct [:path, :file, :start_ts_ms, :rows]
@type t :: %__MODULE__{
path: Path.t(),
file: File.io_device() | nil,
start_ts_ms: integer(),
rows: non_neg_integer()
}
@header "ts_ms,elapsed_sec,reachability,app_process,usb,screen,battery_pct,reason\n"
@doc """
Open a log file for writing. Creates parent dirs as needed.
Returns a struct that's passed to subsequent `append/2` and `close/1` calls.
"""
@spec open(Path.t(), keyword()) :: t()
def open(path, opts \\ []) do
File.mkdir_p!(Path.dirname(path))
file = File.open!(path, [:write, :utf8])
IO.write(file, @header)
%__MODULE__{
path: path,
file: file,
start_ts_ms: Keyword.get(opts, :start_ts_ms, System.monotonic_time(:millisecond)),
rows: 0
}
end
@doc """
Append a probe snapshot. Returns the updated logger struct.
"""
@spec append(t(), Probe.t()) :: t()
def append(%__MODULE__{file: file} = log, %Probe{} = probe) when is_pid(file) do
elapsed_sec =
Float.round((probe.ts_ms - log.start_ts_ms) / 1000.0, 2)
line =
[
Integer.to_string(probe.ts_ms),
:erlang.float_to_binary(elapsed_sec, decimals: 2),
Atom.to_string(probe.reachability),
Atom.to_string(probe.app_process),
Atom.to_string(probe.usb),
Atom.to_string(probe.screen),
if(probe.battery_pct, do: Integer.to_string(probe.battery_pct), else: ""),
csv_escape(probe.reason)
]
|> Enum.join(",")
IO.write(file, line <> "\n")
%{log | rows: log.rows + 1}
end
@doc """
Close the log file. Idempotent.
"""
@spec close(t()) :: t()
def close(%__MODULE__{file: nil} = log), do: log
def close(%__MODULE__{file: file} = log) when is_pid(file) do
File.close(file)
%{log | file: nil}
end
@doc """
Read a CSV file and return a list of probe-like maps. Useful for tests
and for `summary/1`.
Each row is `%{ts_ms, elapsed_sec, reachability, app_process, usb,
screen, battery_pct, reason}` with atoms restored.
"""
@spec read(Path.t()) :: [map()]
def read(path) do
path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.drop(1)
|> Enum.map(&parse_row/1)
end
defp parse_row(line) do
[ts_ms, elapsed_sec, reach, app, usb, screen, battery, reason] =
split_csv(line, 8)
%{
ts_ms: String.to_integer(ts_ms),
elapsed_sec: String.to_float(elapsed_sec),
reachability: String.to_atom(reach),
app_process: String.to_atom(app),
usb: String.to_atom(usb),
screen: String.to_atom(screen),
battery_pct: if(battery == "", do: nil, else: String.to_integer(battery)),
reason: csv_unescape(reason)
}
end
# ── CSV helpers ──────────────────────────────────────────────────────────
#
# Strategy: sanitize reasons on write (replace newlines / commas /
# double-quotes with escape sequences) so each row is exactly one line
# with comma-separated fields. Avoids the complexity of a full CSV
# state-machine parser for a log format that's only consumed by us.
#
# Encoding for `reason`:
# newline → \n (literal two chars)
# tab → \t
# comma → \,
# backslash → \\
# Other fields are atoms / integers — never need escaping.
defp csv_escape(nil), do: ""
defp csv_escape(str) when is_binary(str) do
str
|> String.replace("\\", "\\\\")
|> String.replace(",", "\\,")
|> String.replace("\n", "\\n")
|> String.replace("\r", "\\r")
|> String.replace("\t", "\\t")
end
defp csv_unescape(""), do: nil
defp csv_unescape(str) when is_binary(str) do
unescape(str, [])
end
# Walks the string character by character, recognising backslash-escapes.
defp unescape("", acc), do: acc |> Enum.reverse() |> List.to_string()
defp unescape("\\\\" <> rest, acc), do: unescape(rest, [?\\ | acc])
defp unescape("\\n" <> rest, acc), do: unescape(rest, [?\n | acc])
defp unescape("\\r" <> rest, acc), do: unescape(rest, [?\r | acc])
defp unescape("\\t" <> rest, acc), do: unescape(rest, [?\t | acc])
defp unescape("\\," <> rest, acc), do: unescape(rest, [?, | acc])
defp unescape(<<c, rest::binary>>, acc), do: unescape(rest, [c | acc])
defp split_csv(line, fields) when is_binary(line) and is_integer(fields) do
do_split(line, fields, [], [])
end
defp do_split("", _fields, current, acc) do
final = current |> Enum.reverse() |> List.to_string()
Enum.reverse([final | acc])
end
defp do_split("\\" <> <<c, rest::binary>>, fields, current, acc) do
# Preserve escape — the unescape pass restores it later.
do_split(rest, fields, [c, ?\\ | current], acc)
end
defp do_split("," <> rest, fields, current, acc) do
field = current |> Enum.reverse() |> List.to_string()
do_split(rest, fields, [], [field | acc])
end
defp do_split(<<c, rest::binary>>, fields, current, acc) do
do_split(rest, fields, [c | current], acc)
end
end