Persist telemetry data in local sqlite database
This commit is contained in:
@@ -24,6 +24,7 @@ services:
|
||||
- POOL_SIZE
|
||||
- PORT
|
||||
- SECRET_KEY_BASE
|
||||
- TELEMETRY_DATABASE_PATH
|
||||
- TIMEZONE
|
||||
volumes:
|
||||
- /data/coolify/applications/music-library:/mnt/music_library
|
||||
|
||||
+5
-1
@@ -14,7 +14,7 @@ config :elixir, :time_zone_database, TimeZoneInfo.TimeZoneDatabase
|
||||
config :time_zone_info, update: :daily
|
||||
|
||||
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]
|
||||
|
||||
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.TelemetryRepo, priv: "priv/telemetry_repo", log: false
|
||||
|
||||
config :music_library, MusicLibraryWeb.Telemetry.Storage, buffer_size: 1024
|
||||
|
||||
config :swoosh, :api_client, Swoosh.ApiClient.Req
|
||||
|
||||
config :music_library, MusicLibrary.Mailer,
|
||||
|
||||
@@ -14,6 +14,12 @@ config :music_library, MusicLibrary.BackgroundRepo,
|
||||
stacktrace: 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
|
||||
# debugging and code reloading.
|
||||
#
|
||||
|
||||
@@ -117,6 +117,19 @@ if config_env() == :prod do
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5"),
|
||||
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.
|
||||
# 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
|
||||
|
||||
@@ -21,6 +21,15 @@ config :music_library, MusicLibrary.BackgroundRepo,
|
||||
pool_size: 32,
|
||||
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,
|
||||
# you can enable the server option below.
|
||||
config :music_library, MusicLibraryWeb.Endpoint,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
## Overview
|
||||
|
||||
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
|
||||
jobs (Oban). All schemas use `binary_id` primary keys.
|
||||
Uses SQLite (via `ecto_sqlite3`) with three databases: one for the app, one for background
|
||||
jobs (Oban), and one for telemetry metrics. All schemas use `binary_id` primary keys.
|
||||
|
||||
Key capabilities:
|
||||
- Browse/search collected and wishlisted records
|
||||
@@ -31,8 +31,9 @@ MusicLibrary.Application (one_for_one)
|
||||
├── MusicLibrary.Vault # Cloak encryption vault
|
||||
├── MusicLibrary.Repo # Main SQLite repo
|
||||
├── MusicLibrary.BackgroundRepo # Oban SQLite repo (separate DB)
|
||||
├── MusicLibrary.TelemetryRepo # Telemetry metrics SQLite repo
|
||||
├── MusicLibraryWeb.Telemetry # Telemetry supervisor
|
||||
│ ├── Telemetry.Storage # Metrics storage (circular buffer)
|
||||
│ ├── Telemetry.Storage # Metrics storage (SQLite-backed, persistent)
|
||||
│ └── :telemetry_poller # 30s periodic measurements
|
||||
├── Oban # Background job engine
|
||||
├── 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.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).
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ defmodule MusicLibrary.Application do
|
||||
MusicLibrary.Vault,
|
||||
MusicLibrary.Repo,
|
||||
MusicLibrary.BackgroundRepo,
|
||||
MusicLibrary.TelemetryRepo,
|
||||
MusicLibraryWeb.Telemetry,
|
||||
{Oban, Application.fetch_env!(:music_library, Oban)},
|
||||
{Ecto.Migrator,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
defmodule MusicLibrary.TelemetryRepo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :music_library,
|
||||
adapter: Ecto.Adapters.SQLite3
|
||||
end
|
||||
@@ -1,7 +1,11 @@
|
||||
defmodule MusicLibraryWeb.Telemetry.Storage do
|
||||
use GenServer
|
||||
|
||||
@history_buffer_size 50
|
||||
@buffer_size Application.compile_env(
|
||||
:music_library,
|
||||
[__MODULE__, :buffer_size],
|
||||
50
|
||||
)
|
||||
|
||||
def metrics_history(metric) do
|
||||
GenServer.call(__MODULE__, {:data, metric})
|
||||
@@ -15,14 +19,11 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
|
||||
def init(metrics) do
|
||||
Process.flag(:trap_exit, true)
|
||||
|
||||
metric_histories_map =
|
||||
metrics
|
||||
|> Map.new(fn metric ->
|
||||
attach_handler(metric)
|
||||
{metric, CircularBuffer.new(@history_buffer_size)}
|
||||
end)
|
||||
for metric <- metrics do
|
||||
attach_handler(metric)
|
||||
end
|
||||
|
||||
{:ok, metric_histories_map}
|
||||
{:ok, metrics}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -51,15 +52,68 @@ defmodule MusicLibraryWeb.Telemetry.Storage do
|
||||
|
||||
@impl true
|
||||
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
|
||||
|
||||
@impl true
|
||||
def handle_call({:data, metric}, _from, state) do
|
||||
if history = state[metric] do
|
||||
{:reply, CircularBuffer.to_list(history), state}
|
||||
else
|
||||
{:reply, [], state}
|
||||
key = metric_key(metric)
|
||||
datapoints = fetch_datapoints(key)
|
||||
{:reply, datapoints, 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
|
||||
catch
|
||||
_, _ -> []
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,8 +44,6 @@ defmodule MusicLibrary.MixProject do
|
||||
[
|
||||
# Low-level tooling
|
||||
{:jason, "~> 1.2"},
|
||||
# Version 0.4.2 and 1.0 are identical, so it's safe to override
|
||||
{:circular_buffer, "~> 1.0"},
|
||||
|
||||
# Translations
|
||||
{: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,3 +1,4 @@
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.Repo, :manual)
|
||||
Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.BackgroundRepo, :manual)
|
||||
Ecto.Adapters.SQL.Sandbox.mode(MusicLibrary.TelemetryRepo, :auto)
|
||||
|
||||
Reference in New Issue
Block a user