diff --git a/compose.yaml b/compose.yaml index a0924fe4..4a61fdbf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,7 @@ services: - POOL_SIZE - PORT - SECRET_KEY_BASE + - TELEMETRY_DATABASE_PATH - TIMEZONE volumes: - /data/coolify/applications/music-library:/mnt/music_library diff --git a/config/config.exs b/config/config.exs index 8bf81299..92a06a3f 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/config/dev.exs b/config/dev.exs index a7b55c72..b4f9ea5b 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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. # diff --git a/config/runtime.exs b/config/runtime.exs index 88d56e3c..37d5328b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/config/test.exs b/config/test.exs index ca1cc561..73ed8595 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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, diff --git a/docs/architecture.md b/docs/architecture.md index d31e8c1c..743b672b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). diff --git a/lib/music_library/application.ex b/lib/music_library/application.ex index d7e7a5ac..0bfe4485 100644 --- a/lib/music_library/application.ex +++ b/lib/music_library/application.ex @@ -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, diff --git a/lib/music_library/telemetry_repo.ex b/lib/music_library/telemetry_repo.ex new file mode 100644 index 00000000..46e8a6c1 --- /dev/null +++ b/lib/music_library/telemetry_repo.ex @@ -0,0 +1,5 @@ +defmodule MusicLibrary.TelemetryRepo do + use Ecto.Repo, + otp_app: :music_library, + adapter: Ecto.Adapters.SQLite3 +end diff --git a/lib/music_library_web/telemetry/storage.ex b/lib/music_library_web/telemetry/storage.ex index 1d91aa06..592bffc6 100644 --- a/lib/music_library_web/telemetry/storage.ex +++ b/lib/music_library_web/telemetry/storage.ex @@ -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 diff --git a/mix.exs b/mix.exs index 10929c78..991507d4 100644 --- a/mix.exs +++ b/mix.exs @@ -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"}, diff --git a/priv/telemetry_repo/migrations/20260305115929_create_telemetry_datapoints.exs b/priv/telemetry_repo/migrations/20260305115929_create_telemetry_datapoints.exs new file mode 100644 index 00000000..8f50cf8c --- /dev/null +++ b/priv/telemetry_repo/migrations/20260305115929_create_telemetry_datapoints.exs @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index ef6b774c..a6fa7343 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -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)