Persist telemetry data in local sqlite database
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
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
|
|
||||||
|> Map.new(fn metric ->
|
|
||||||
attach_handler(metric)
|
attach_handler(metric)
|
||||||
{metric, CircularBuffer.new(@history_buffer_size)}
|
end
|
||||||
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
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user