Persist telemetry data in local sqlite database

This commit is contained in:
Claudio Ortolina
2026-03-05 13:37:18 +00:00
parent bb541c8004
commit f109057be5
12 changed files with 128 additions and 19 deletions
+1
View File
@@ -24,6 +24,7 @@ services:
- POOL_SIZE - POOL_SIZE
- PORT - PORT
- SECRET_KEY_BASE - SECRET_KEY_BASE
- TELEMETRY_DATABASE_PATH
- TIMEZONE - TIMEZONE
volumes: volumes:
- /data/coolify/applications/music-library:/mnt/music_library - /data/coolify/applications/music-library:/mnt/music_library
+5 -1
View File
@@ -14,7 +14,7 @@ config :elixir, :time_zone_database, TimeZoneInfo.TimeZoneDatabase
config :time_zone_info, update: :daily config :time_zone_info, update: :daily
config :music_library, config :music_library,
ecto_repos: [MusicLibrary.BackgroundRepo, MusicLibrary.Repo], ecto_repos: [MusicLibrary.BackgroundRepo, MusicLibrary.Repo, MusicLibrary.TelemetryRepo],
generators: [timestamp_type: :utc_datetime, binary_id: true] generators: [timestamp_type: :utc_datetime, binary_id: true]
config :music_library, default_timezone: "Europe/London" config :music_library, default_timezone: "Europe/London"
@@ -112,6 +112,10 @@ config :oban_met, sketch_time_unit: :millisecond
config :music_library, MusicLibrary.BackgroundRepo, priv: "priv/background_repo" config :music_library, MusicLibrary.BackgroundRepo, priv: "priv/background_repo"
config :music_library, MusicLibrary.TelemetryRepo, priv: "priv/telemetry_repo", log: false
config :music_library, MusicLibraryWeb.Telemetry.Storage, buffer_size: 1024
config :swoosh, :api_client, Swoosh.ApiClient.Req config :swoosh, :api_client, Swoosh.ApiClient.Req
config :music_library, MusicLibrary.Mailer, config :music_library, MusicLibrary.Mailer,
+6
View File
@@ -14,6 +14,12 @@ config :music_library, MusicLibrary.BackgroundRepo,
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: true show_sensitive_data_on_connection_error: true
config :music_library, MusicLibrary.TelemetryRepo,
database: Path.expand("../data/music_library_telemetry_dev.db", __DIR__),
pool_size: 3,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable # For development, we disable any cache and enable
# debugging and code reloading. # debugging and code reloading.
# #
+13
View File
@@ -117,6 +117,19 @@ if config_env() == :prod do
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5"), pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5"),
show_sensitive_data_on_connection_error: false show_sensitive_data_on_connection_error: false
telemetry_database_path =
System.get_env("TELEMETRY_DATABASE_PATH") ||
raise """
environment variable TELEMETRY_DATABASE_PATH is missing.
For example: /etc/music_library/music_library_telemetry.db
"""
config :music_library, MusicLibrary.TelemetryRepo,
database: telemetry_database_path,
cache_size: -4000,
pool_size: 2,
show_sensitive_data_on_connection_error: false
# The secret key base is used to sign/encrypt cookies and other secrets. # The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you # A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want # want to use a different value for prod and you most likely don't want
+9
View File
@@ -21,6 +21,15 @@ config :music_library, MusicLibrary.BackgroundRepo,
pool_size: 32, pool_size: 32,
pool: Ecto.Adapters.SQL.Sandbox pool: Ecto.Adapters.SQL.Sandbox
config :music_library, MusicLibrary.TelemetryRepo,
database:
Path.expand(
"../data/music_library_telemetry_test#{System.get_env("MIX_TEST_PARTITION")}.db",
__DIR__
),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
config :music_library, MusicLibraryWeb.Endpoint, config :music_library, MusicLibraryWeb.Endpoint,
+5 -3
View File
@@ -7,8 +7,8 @@
## Overview ## Overview
Phoenix LiveView application for managing a personal music collection and wishlist. Phoenix LiveView application for managing a personal music collection and wishlist.
Uses SQLite (via `ecto_sqlite3`) with two databases: one for the app, one for background Uses SQLite (via `ecto_sqlite3`) with three databases: one for the app, one for background
jobs (Oban). All schemas use `binary_id` primary keys. jobs (Oban), and one for telemetry metrics. All schemas use `binary_id` primary keys.
Key capabilities: Key capabilities:
- Browse/search collected and wishlisted records - Browse/search collected and wishlisted records
@@ -31,8 +31,9 @@ MusicLibrary.Application (one_for_one)
├── MusicLibrary.Vault # Cloak encryption vault ├── MusicLibrary.Vault # Cloak encryption vault
├── MusicLibrary.Repo # Main SQLite repo ├── MusicLibrary.Repo # Main SQLite repo
├── MusicLibrary.BackgroundRepo # Oban SQLite repo (separate DB) ├── MusicLibrary.BackgroundRepo # Oban SQLite repo (separate DB)
├── MusicLibrary.TelemetryRepo # Telemetry metrics SQLite repo
├── MusicLibraryWeb.Telemetry # Telemetry supervisor ├── MusicLibraryWeb.Telemetry # Telemetry supervisor
│ ├── Telemetry.Storage # Metrics storage (circular buffer) │ ├── Telemetry.Storage # Metrics storage (SQLite-backed, persistent)
│ └── :telemetry_poller # 30s periodic measurements │ └── :telemetry_poller # 30s periodic measurements
├── Oban # Background job engine ├── Oban # Background job engine
├── Ecto.Migrator # Auto-migration on boot ├── Ecto.Migrator # Auto-migration on boot
@@ -52,6 +53,7 @@ MusicLibrary.Application (one_for_one)
|------|---------------|---------| |------|---------------|---------|
| `MusicLibrary.Repo` | `data/music_library_dev.db` | All application data | | `MusicLibrary.Repo` | `data/music_library_dev.db` | All application data |
| `MusicLibrary.BackgroundRepo` | `data/music_library_background_dev.db` | Oban job queue | | `MusicLibrary.BackgroundRepo` | `data/music_library_background_dev.db` | Oban job queue |
| `MusicLibrary.TelemetryRepo` | `data/music_library_telemetry_dev.db` | Telemetry metrics history (persistent across restarts) |
SQLite extensions loaded at runtime: `unicode`, `vec0` (vector search). SQLite extensions loaded at runtime: `unicode`, `vec0` (vector search).
+1
View File
@@ -17,6 +17,7 @@ defmodule MusicLibrary.Application do
MusicLibrary.Vault, MusicLibrary.Vault,
MusicLibrary.Repo, MusicLibrary.Repo,
MusicLibrary.BackgroundRepo, MusicLibrary.BackgroundRepo,
MusicLibrary.TelemetryRepo,
MusicLibraryWeb.Telemetry, MusicLibraryWeb.Telemetry,
{Oban, Application.fetch_env!(:music_library, Oban)}, {Oban, Application.fetch_env!(:music_library, Oban)},
{Ecto.Migrator, {Ecto.Migrator,
+5
View File
@@ -0,0 +1,5 @@
defmodule MusicLibrary.TelemetryRepo do
use Ecto.Repo,
otp_app: :music_library,
adapter: Ecto.Adapters.SQLite3
end
+67 -13
View File
@@ -1,7 +1,11 @@
defmodule MusicLibraryWeb.Telemetry.Storage do defmodule MusicLibraryWeb.Telemetry.Storage do
use GenServer use GenServer
@history_buffer_size 50 @buffer_size Application.compile_env(
:music_library,
[__MODULE__, :buffer_size],
50
)
def metrics_history(metric) do def metrics_history(metric) do
GenServer.call(__MODULE__, {:data, metric}) GenServer.call(__MODULE__, {:data, metric})
@@ -15,14 +19,11 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
def init(metrics) do def init(metrics) do
Process.flag(:trap_exit, true) Process.flag(:trap_exit, true)
metric_histories_map = for metric <- metrics do
metrics attach_handler(metric)
|> Map.new(fn metric -> end
attach_handler(metric)
{metric, CircularBuffer.new(@history_buffer_size)}
end)
{:ok, metric_histories_map} {:ok, metrics}
end end
@impl true @impl true
@@ -51,15 +52,68 @@ 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
{:noreply, update_in(state[metric], &CircularBuffer.insert(&1, data))} key = metric_key(metric)
label = if is_map(data), do: Map.get(data, :label)
measurement = if is_map(data), do: Map.get(data, :measurement, 0), else: 0
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
if history = state[metric] do key = metric_key(metric)
{:reply, CircularBuffer.to_list(history), state} datapoints = fetch_datapoints(key)
else {:reply, datapoints, state}
{:reply, [], state} end
defp metric_key(metric) do
"#{inspect(metric.__struct__)}:#{Enum.join(metric.name, ".")}"
end
defp insert_and_prune(key, label, measurement, time) do
MusicLibrary.TelemetryRepo.query(
"INSERT INTO telemetry_datapoints (metric_key, label, measurement, time) VALUES (?1, ?2, ?3, ?4)",
[key, label, measurement, time]
)
MusicLibrary.TelemetryRepo.query(
"""
DELETE FROM telemetry_datapoints
WHERE metric_key = ?1
AND id NOT IN (
SELECT id FROM telemetry_datapoints
WHERE metric_key = ?1
ORDER BY id DESC
LIMIT ?2
)
""",
[key, @buffer_size]
)
catch
_, _ -> :ok
end
defp fetch_datapoints(key) do
case MusicLibrary.TelemetryRepo.query(
"SELECT label, measurement, time FROM telemetry_datapoints WHERE metric_key = ?1 ORDER BY time ASC",
[key]
) do
{:ok, %{rows: rows}} ->
Enum.map(rows, fn [label, measurement, time] ->
%{label: label, measurement: measurement, time: time}
end)
_ ->
[]
end end
catch
_, _ -> []
end end
end end
-2
View File
@@ -44,8 +44,6 @@ defmodule MusicLibrary.MixProject do
[ [
# Low-level tooling # Low-level tooling
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
# Version 0.4.2 and 1.0 are identical, so it's safe to override
{:circular_buffer, "~> 1.0"},
# Translations # Translations
{:gettext, "~> 1.0"}, {:gettext, "~> 1.0"},
@@ -0,0 +1,15 @@
defmodule MusicLibrary.TelemetryRepo.Migrations.CreateTelemetryDatapoints do
use Ecto.Migration
def change do
create table(:telemetry_datapoints) do
add :metric_key, :text, null: false
add :label, :text
add :measurement, :real, null: false
add :time, :integer, null: false
end
# Serves read queries (ORDER BY time) and prune subselect (ORDER BY id DESC)
create index(:telemetry_datapoints, [:metric_key, :time])
end
end
+1
View File
@@ -1,3 +1,4 @@
ExUnit.start() ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.Repo, :manual)
Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.BackgroundRepo, :manual) Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.BackgroundRepo, :manual)
Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.TelemetryRepo, :auto)