ML-11: Improve performance of Telemetry.Storage with in-memory buffer
This commit is contained in:
+5
-4
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-11
|
id: ML-11
|
||||||
title: Telemetry.Storage GenServer funnels synchronous SQLite writes
|
title: Telemetry.Storage GenServer funnels synchronous SQLite writes
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-04-20 08:49'
|
created_date: '2026-04-20 08:49'
|
||||||
|
updated_date: '2026-04-24 09:29'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -47,7 +48,7 @@ Whichever option is chosen, replace the bare `catch _, _` with either a targeted
|
|||||||
- Existing dashboard queries continue to work
|
- Existing dashboard queries continue to work
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
- [ ] #1 Telemetry write path does not block on the GenServer mailbox
|
- [x] #1 Telemetry write path does not block on the GenServer mailbox
|
||||||
- [ ] #2 Error path surfaces at least once per distinct failure (not silently swallowed)
|
- [x] #2 Error path surfaces at least once per distinct failure (not silently swallowed)
|
||||||
- [ ] #3 Existing dashboard queries continue to work
|
- [x] #3 Existing dashboard queries continue to work
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|||||||
+3
-1
@@ -122,7 +122,9 @@ config :music_library, MusicLibrary.BackgroundRepo, priv: "priv/background_repo"
|
|||||||
|
|
||||||
config :music_library, MusicLibrary.TelemetryRepo, priv: "priv/telemetry_repo", log: false
|
config :music_library, MusicLibrary.TelemetryRepo, priv: "priv/telemetry_repo", log: false
|
||||||
|
|
||||||
config :music_library, MusicLibraryWeb.Telemetry.Storage, buffer_size: 32_768
|
config :music_library, MusicLibraryWeb.Telemetry.Storage,
|
||||||
|
retention_limit: 32_768,
|
||||||
|
flush_interval_ms: 5_000
|
||||||
|
|
||||||
config :swoosh, :api_client, Swoosh.ApiClient.Req
|
config :swoosh, :api_client, Swoosh.ApiClient.Req
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ MusicLibrary.Application (one_for_one)
|
|||||||
├── MusicLibrary.BackgroundRepo # Oban SQLite repo (separate DB)
|
├── MusicLibrary.BackgroundRepo # Oban SQLite repo (separate DB)
|
||||||
├── MusicLibrary.TelemetryRepo # Telemetry metrics SQLite repo
|
├── MusicLibrary.TelemetryRepo # Telemetry metrics SQLite repo
|
||||||
├── MusicLibraryWeb.Telemetry # Telemetry supervisor
|
├── MusicLibraryWeb.Telemetry # Telemetry supervisor
|
||||||
│ ├── Telemetry.Storage # Metrics storage (SQLite-backed, persistent)
|
│ ├── Telemetry.Storage # Buffered metrics storage (in-memory, 5s flush to SQLite, force-flush on read)
|
||||||
│ └── :telemetry_poller # 30s periodic measurements
|
│ └── :telemetry_poller # 30s periodic measurements
|
||||||
├── Oban # Background job engine
|
├── Oban # Background job engine
|
||||||
├── Ecto.Migrator # Migrations (skipped in release; run by Coolify post-deploy)
|
├── Ecto.Migrator # Migrations (skipped in release; run by Coolify post-deploy)
|
||||||
@@ -420,3 +420,7 @@ All events are namespaced with `music_library:` prefix.
|
|||||||
- **`use MusicLibraryWeb, :live_view`** imports: Phoenix.LiveView, Fluxon, Gettext, LiveToast,
|
- **`use MusicLibraryWeb, :live_view`** imports: Phoenix.LiveView, Fluxon, Gettext, LiveToast,
|
||||||
CoreComponents, Phoenix.HTML, verified routes, JS alias
|
CoreComponents, Phoenix.HTML, verified routes, JS alias
|
||||||
- **`use MusicLibraryWeb, :live_component`** additionally imports `put_toast!/2`
|
- **`use MusicLibraryWeb, :live_component`** additionally imports `put_toast!/2`
|
||||||
|
- **Telemetry buffering**: `Telemetry.Storage` keeps incoming datapoints in an in-memory map
|
||||||
|
keyed by metric and flushes to SQLite on a 5 s timer, on shutdown, and synchronously when
|
||||||
|
`metrics_history/1` is called (only for the requested metric). Keeps the cast path O(1) and
|
||||||
|
the dashboard read path consistent.
|
||||||
|
|||||||
@@ -205,7 +205,12 @@ checks and post-deploy verification.
|
|||||||
### Telemetry
|
### Telemetry
|
||||||
|
|
||||||
SQLite-backed persistent metrics (`MusicLibraryWeb.Telemetry.Storage`) with 30-second
|
SQLite-backed persistent metrics (`MusicLibraryWeb.Telemetry.Storage`) with 30-second
|
||||||
polling interval. Tracks:
|
polling interval. Events are buffered in GenServer state keyed by metric and flushed to
|
||||||
|
SQLite every 5 seconds inside a single transaction; reads via `metrics_history/1`
|
||||||
|
force-flush only the requested metric so the dashboard sees fresh data without waiting
|
||||||
|
for the next tick. Per-metric retention is capped at 32 768 rows
|
||||||
|
(`:retention_limit`), pruned after each flush. Flush failures are logged at `:warning`
|
||||||
|
and the offending batch is dropped. Tracks:
|
||||||
|
|
||||||
- Database query times (total, query, queue)
|
- Database query times (total, query, queue)
|
||||||
- External API request latency (Finch)
|
- External API request latency (Finch)
|
||||||
|
|||||||
@@ -1,36 +1,74 @@
|
|||||||
defmodule MusicLibraryWeb.Telemetry.Storage do
|
defmodule MusicLibraryWeb.Telemetry.Storage do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Telemetry metrics storage backed by SQLite with an in-memory write buffer.
|
||||||
|
|
||||||
|
Telemetry events arrive via `:telemetry` handlers and are forwarded through
|
||||||
|
a fast cast to this GenServer. Each cast prepends a datapoint to an
|
||||||
|
in-state buffer keyed by metric; no disk I/O happens on the cast path.
|
||||||
|
|
||||||
|
Buffered datapoints are flushed to SQLite on three triggers:
|
||||||
|
|
||||||
|
* a periodic timer (`:flush_interval_ms`, default 5s) which drains the
|
||||||
|
full buffer inside a single transaction;
|
||||||
|
* a call to `metrics_history/1`, which force-flushes only the buffer
|
||||||
|
entry for the requested metric so readers see fresh data without
|
||||||
|
waiting for the next tick;
|
||||||
|
* `terminate/2`, so graceful shutdown does not lose buffered datapoints.
|
||||||
|
|
||||||
|
Per metric key, at most `:retention_limit` rows (default 32 768) are kept;
|
||||||
|
older rows are pruned after each flush.
|
||||||
|
|
||||||
|
Flush failures are logged at `:warning` level; the offending batch is
|
||||||
|
dropped (telemetry is not authoritative) and the process keeps running.
|
||||||
|
"""
|
||||||
|
|
||||||
use GenServer
|
use GenServer
|
||||||
|
|
||||||
@buffer_size Application.compile_env(
|
require Logger
|
||||||
:music_library,
|
|
||||||
[__MODULE__, :buffer_size],
|
@retention_limit Application.compile_env!(:music_library, [__MODULE__, :retention_limit])
|
||||||
50
|
@flush_interval_ms Application.compile_env!(:music_library, [__MODULE__, :flush_interval_ms])
|
||||||
)
|
@insert_chunk_size 200
|
||||||
|
|
||||||
def metrics_history(metric) do
|
def metrics_history(metric) do
|
||||||
GenServer.call(__MODULE__, {:data, metric})
|
GenServer.call(__MODULE__, {:data, metric})
|
||||||
end
|
end
|
||||||
|
|
||||||
def start_link(args) do
|
def start_link(args) do
|
||||||
GenServer.start_link(__MODULE__, args, name: __MODULE__)
|
{metrics, opts} =
|
||||||
|
case args do
|
||||||
|
{metrics, opts} when is_list(metrics) and is_list(opts) -> {metrics, opts}
|
||||||
|
metrics when is_list(metrics) -> {metrics, [name: __MODULE__]}
|
||||||
|
end
|
||||||
|
|
||||||
|
{server_opts, init_opts} = Keyword.split(opts, [:name])
|
||||||
|
GenServer.start_link(__MODULE__, {metrics, init_opts}, server_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(metrics) do
|
def init({metrics, opts}) do
|
||||||
Process.flag(:trap_exit, true)
|
Process.flag(:trap_exit, true)
|
||||||
|
|
||||||
for metric <- metrics do
|
for metric <- metrics do
|
||||||
attach_handler(metric)
|
attach_handler(metric)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, metrics}
|
state = %{
|
||||||
|
metrics: metrics,
|
||||||
|
buffer: %{},
|
||||||
|
retention_limit: Keyword.get(opts, :retention_limit, @retention_limit),
|
||||||
|
flush_interval_ms: Keyword.get(opts, :flush_interval_ms, @flush_interval_ms),
|
||||||
|
flush_ref: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, %{state | flush_ref: schedule_flush(state.flush_interval_ms)}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def terminate(_, metrics) do
|
def terminate(_reason, state) do
|
||||||
for metric <- metrics do
|
_ = flush_all(state)
|
||||||
|
|
||||||
|
for metric <- state.metrics do
|
||||||
:telemetry.detach({__MODULE__, metric, self()})
|
:telemetry.detach({__MODULE__, metric, self()})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,24 +93,38 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:telemetry_metric, data, metric}, state) do
|
def handle_cast({:telemetry_metric, data, metric}, state) do
|
||||||
key = metric_key(metric)
|
key = metric_key(metric)
|
||||||
label = if is_map(data), do: Map.get(data, :label)
|
entry = datapoint_from_data(data)
|
||||||
measurement = if is_map(data), do: Map.get(data, :measurement, 0), else: 0
|
buffer = Map.update(state.buffer, key, [entry], &[entry | &1])
|
||||||
|
{:noreply, %{state | buffer: buffer}}
|
||||||
time =
|
|
||||||
if is_map(data),
|
|
||||||
do: Map.get(data, :time, System.system_time(:microsecond)),
|
|
||||||
else: System.system_time(:microsecond)
|
|
||||||
|
|
||||||
insert_and_prune(key, label, measurement, time)
|
|
||||||
|
|
||||||
{:noreply, state}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:data, metric}, _from, state) do
|
def handle_call({:data, metric}, _from, state) do
|
||||||
key = metric_key(metric)
|
key = metric_key(metric)
|
||||||
datapoints = fetch_datapoints(key)
|
state = flush_key(state, key)
|
||||||
{:reply, datapoints, state}
|
{:reply, fetch_datapoints(key), state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:flush, state) do
|
||||||
|
state = flush_all(state)
|
||||||
|
{:noreply, %{state | flush_ref: schedule_flush(state.flush_interval_ms)}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_flush(interval_ms) do
|
||||||
|
Process.send_after(self(), :flush, interval_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp datapoint_from_data(data) when is_map(data) do
|
||||||
|
%{
|
||||||
|
label: Map.get(data, :label),
|
||||||
|
measurement: Map.get(data, :measurement, 0),
|
||||||
|
time: Map.get(data, :time, System.system_time(:microsecond))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp datapoint_from_data(_data) do
|
||||||
|
%{label: nil, measurement: 0, time: System.system_time(:microsecond)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp metric_key(metric) do
|
defp metric_key(metric) do
|
||||||
@@ -86,13 +138,69 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp insert_and_prune(key, label, measurement, time) do
|
defp flush_all(%{buffer: buffer} = state) when map_size(buffer) == 0, do: state
|
||||||
MusicLibrary.TelemetryRepo.query(
|
|
||||||
"INSERT INTO telemetry_datapoints (metric_key, label, measurement, time) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
[key, label, measurement, time]
|
|
||||||
)
|
|
||||||
|
|
||||||
MusicLibrary.TelemetryRepo.query(
|
defp flush_all(state) do
|
||||||
|
keys = Map.keys(state.buffer)
|
||||||
|
|
||||||
|
try do
|
||||||
|
MusicLibrary.TelemetryRepo.transaction(fn ->
|
||||||
|
Enum.each(keys, &persist_key(state, &1))
|
||||||
|
end)
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
Logger.warning(
|
||||||
|
"[Telemetry.Storage] flush failed: " <> Exception.format(:error, error, __STACKTRACE__)
|
||||||
|
)
|
||||||
|
catch
|
||||||
|
kind, reason ->
|
||||||
|
Logger.warning("[Telemetry.Storage] flush #{kind}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
%{state | buffer: %{}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp flush_key(state, key) do
|
||||||
|
case Map.get(state.buffer, key) do
|
||||||
|
nil ->
|
||||||
|
state
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
%{state | buffer: Map.delete(state.buffer, key)}
|
||||||
|
|
||||||
|
_entries ->
|
||||||
|
try do
|
||||||
|
MusicLibrary.TelemetryRepo.transaction(fn ->
|
||||||
|
persist_key(state, key)
|
||||||
|
end)
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
Logger.warning(
|
||||||
|
"[Telemetry.Storage] flush failed for #{key}: " <>
|
||||||
|
Exception.format(:error, error, __STACKTRACE__)
|
||||||
|
)
|
||||||
|
catch
|
||||||
|
kind, reason ->
|
||||||
|
Logger.warning("[Telemetry.Storage] flush #{kind} for #{key}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
%{state | buffer: Map.delete(state.buffer, key)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persist_key(state, key) do
|
||||||
|
case Map.get(state.buffer, key, []) do
|
||||||
|
[] ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
entries ->
|
||||||
|
entries
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.map(&Map.put(&1, :metric_key, key))
|
||||||
|
|> Enum.chunk_every(@insert_chunk_size)
|
||||||
|
|> Enum.each(&MusicLibrary.TelemetryRepo.insert_all("telemetry_datapoints", &1))
|
||||||
|
|
||||||
|
MusicLibrary.TelemetryRepo.query!(
|
||||||
"""
|
"""
|
||||||
DELETE FROM telemetry_datapoints
|
DELETE FROM telemetry_datapoints
|
||||||
WHERE metric_key = ?1
|
WHERE metric_key = ?1
|
||||||
@@ -103,10 +211,11 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
|
|||||||
LIMIT ?2
|
LIMIT ?2
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
[key, @buffer_size]
|
[key, state.retention_limit]
|
||||||
)
|
)
|
||||||
catch
|
|
||||||
_, _ -> :ok
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_datapoints(key) do
|
defp fetch_datapoints(key) do
|
||||||
@@ -119,10 +228,17 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
|
|||||||
%{label: label, measurement: measurement, time: time}
|
%{label: label, measurement: measurement, time: time}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
_ ->
|
{:error, reason} ->
|
||||||
|
Logger.warning("[Telemetry.Storage] fetch failed for #{key}: #{inspect(reason)}")
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
catch
|
rescue
|
||||||
_, _ -> []
|
error ->
|
||||||
|
Logger.warning(
|
||||||
|
"[Telemetry.Storage] fetch raised for #{key}: " <>
|
||||||
|
Exception.format(:error, error, __STACKTRACE__)
|
||||||
|
)
|
||||||
|
|
||||||
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
defmodule MusicLibraryWeb.Telemetry.StorageTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
import ExUnit.CaptureLog
|
||||||
|
import Telemetry.Metrics, only: [summary: 2]
|
||||||
|
|
||||||
|
alias MusicLibrary.TelemetryRepo
|
||||||
|
alias MusicLibraryWeb.Telemetry.Storage
|
||||||
|
|
||||||
|
setup do
|
||||||
|
id = System.unique_integer([:positive])
|
||||||
|
metric = summary("music_library.test.metric_#{id}.duration", tags: [])
|
||||||
|
pid = start_supervised!({Storage, {[], [flush_interval_ms: 60_000]}})
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
TelemetryRepo.query!(
|
||||||
|
"DELETE FROM telemetry_datapoints WHERE metric_key LIKE ?1",
|
||||||
|
["%metric_#{id}%"]
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{pid: pid, metric: metric, id: id}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_cast/2" do
|
||||||
|
test "buffers datapoints in-memory without persisting", %{pid: pid, metric: metric} do
|
||||||
|
cast_point(pid, metric, 1.0, 100)
|
||||||
|
cast_point(pid, metric, 2.0, 200)
|
||||||
|
|
||||||
|
%{buffer: buffer} = :sys.get_state(pid)
|
||||||
|
key = metric_key(metric)
|
||||||
|
|
||||||
|
assert [%{measurement: 2.0, time: 200}, %{measurement: 1.0, time: 100}] =
|
||||||
|
Map.get(buffer, key)
|
||||||
|
|
||||||
|
assert db_count(key) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "many casts return quickly (no sync I/O on cast path)", %{pid: pid, metric: metric} do
|
||||||
|
{time_us, :ok} =
|
||||||
|
:timer.tc(fn ->
|
||||||
|
for _ <- 1..500, do: cast_point(pid, metric, 1.0, 0)
|
||||||
|
_ = :sys.get_state(pid)
|
||||||
|
:ok
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert time_us < 200_000, "500 casts took #{time_us}us"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_call({:data, metric}, ...)" do
|
||||||
|
test "force-flushes the requested metric and returns persisted rows", %{
|
||||||
|
pid: pid,
|
||||||
|
metric: metric
|
||||||
|
} do
|
||||||
|
cast_point(pid, metric, 42.0, 1000)
|
||||||
|
|
||||||
|
result = GenServer.call(pid, {:data, metric})
|
||||||
|
|
||||||
|
assert [%{measurement: m, time: 1000, label: nil}] = result
|
||||||
|
assert_in_delta m, 42.0, 0.001
|
||||||
|
assert db_count(metric_key(metric)) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "only flushes the requested metric, leaves others buffered", %{
|
||||||
|
pid: pid,
|
||||||
|
metric: metric_a,
|
||||||
|
id: id
|
||||||
|
} do
|
||||||
|
metric_b = summary("music_library.test.metric_#{id}_b.duration", tags: [])
|
||||||
|
|
||||||
|
cast_point(pid, metric_a, 1.0, 1)
|
||||||
|
cast_point(pid, metric_b, 2.0, 2)
|
||||||
|
|
||||||
|
_ = GenServer.call(pid, {:data, metric_a})
|
||||||
|
|
||||||
|
assert db_count(metric_key(metric_a)) == 1
|
||||||
|
assert db_count(metric_key(metric_b)) == 0
|
||||||
|
|
||||||
|
%{buffer: buffer} = :sys.get_state(pid)
|
||||||
|
refute Map.has_key?(buffer, metric_key(metric_a))
|
||||||
|
assert [%{measurement: 2.0}] = Map.get(buffer, metric_key(metric_b))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list when nothing has been cast", %{pid: pid, metric: metric} do
|
||||||
|
assert GenServer.call(pid, {:data, metric}) == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "periodic :flush" do
|
||||||
|
test "drains the full buffer to SQLite", %{pid: pid, metric: metric, id: id} do
|
||||||
|
metric_b = summary("music_library.test.metric_#{id}_b.duration", tags: [])
|
||||||
|
cast_point(pid, metric, 1.5, 10)
|
||||||
|
cast_point(pid, metric_b, 2.5, 20)
|
||||||
|
|
||||||
|
send(pid, :flush)
|
||||||
|
_ = :sys.get_state(pid)
|
||||||
|
|
||||||
|
assert db_count(metric_key(metric)) == 1
|
||||||
|
assert db_count(metric_key(metric_b)) == 1
|
||||||
|
|
||||||
|
%{buffer: buffer} = :sys.get_state(pid)
|
||||||
|
assert buffer == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prunes rows above retention_limit per metric", %{id: id} do
|
||||||
|
metric = summary("music_library.test.metric_#{id}_r.duration", tags: [])
|
||||||
|
|
||||||
|
pid =
|
||||||
|
start_supervised!({Storage, {[], [retention_limit: 3, flush_interval_ms: 60_000]}},
|
||||||
|
id: :storage_retention
|
||||||
|
)
|
||||||
|
|
||||||
|
for n <- 1..10, do: cast_point(pid, metric, n / 1.0, n)
|
||||||
|
send(pid, :flush)
|
||||||
|
_ = :sys.get_state(pid)
|
||||||
|
|
||||||
|
key = metric_key(metric)
|
||||||
|
assert db_count(key) == 3
|
||||||
|
|
||||||
|
{:ok, %{rows: rows}} =
|
||||||
|
TelemetryRepo.query(
|
||||||
|
"SELECT time FROM telemetry_datapoints WHERE metric_key = ?1 ORDER BY time ASC",
|
||||||
|
[key]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rows == [[8], [9], [10]]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "terminate/2" do
|
||||||
|
test "flushes the buffer on shutdown", %{pid: pid, metric: metric} do
|
||||||
|
cast_point(pid, metric, 7.0, 5000)
|
||||||
|
|
||||||
|
:ok = stop_supervised(MusicLibraryWeb.Telemetry.Storage)
|
||||||
|
|
||||||
|
{:ok, %{rows: rows}} =
|
||||||
|
TelemetryRepo.query(
|
||||||
|
"SELECT measurement, time FROM telemetry_datapoints WHERE metric_key = ?1",
|
||||||
|
[metric_key(metric)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [[_, 5000]] = rows
|
||||||
|
refute Process.alive?(pid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "error handling" do
|
||||||
|
test "logs a warning and keeps running when the flush raises", %{pid: pid, metric: metric} do
|
||||||
|
bad = %{measurement: {:not_a_number}, time: 1}
|
||||||
|
GenServer.cast(pid, {:telemetry_metric, bad, metric})
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
send(pid, :flush)
|
||||||
|
_ = :sys.get_state(pid)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert log =~ "[Telemetry.Storage] flush"
|
||||||
|
assert Process.alive?(pid)
|
||||||
|
|
||||||
|
%{buffer: buffer} = :sys.get_state(pid)
|
||||||
|
assert buffer == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cast_point(pid, metric, measurement, time) do
|
||||||
|
GenServer.cast(
|
||||||
|
pid,
|
||||||
|
{:telemetry_metric, %{measurement: measurement, time: time}, metric}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp db_count(key) do
|
||||||
|
{:ok, %{rows: [[count]]}} =
|
||||||
|
TelemetryRepo.query(
|
||||||
|
"SELECT COUNT(*) FROM telemetry_datapoints WHERE metric_key = ?1",
|
||||||
|
[key]
|
||||||
|
)
|
||||||
|
|
||||||
|
count
|
||||||
|
end
|
||||||
|
|
||||||
|
defp metric_key(metric) do
|
||||||
|
Enum.join(
|
||||||
|
[
|
||||||
|
inspect(metric.__struct__),
|
||||||
|
Enum.join(metric.name, "."),
|
||||||
|
Enum.join(metric.tags, ".")
|
||||||
|
],
|
||||||
|
":"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user