Unified Observability for Phoenix
“I found it ironic that the first thing you do to time series data is squash the timestamp. That’s how the name Timeless was born.” –Mark Cotner
Unified observability for Phoenix: persistent metrics, logs, and traces in LiveDashboard.
One dep, one child_spec, one router macro — you get:
- Metrics — TimelessMetrics stores telemetry metrics that survive restarts
- Logs — TimelessLogs captures and indexes Elixir Logger output
- Traces — TimelessTraces stores OpenTelemetry spans
- Dashboard — All three as LiveDashboard pages, plus built-in charts with history
Documentation
- Getting Started
- Configuration Reference
- Architecture
- Dashboard
- Metrics
- Demo Traffic Generator
- Production Deployment
- Interactive Demo Livebook
Installation
With Igniter (recommended)
Add the dependency to mix.exs:
{:timeless_phoenix, "~> 1.5"},
{:igniter, "~> 0.6", only: [:dev, :test], runtime: false}Then run:
mix deps.get
mix igniter.install timeless_phoenixThis automatically:
-
Adds
{TimelessPhoenix, ...}to your supervision tree - Configures OpenTelemetry to export spans to TimelessTraces
-
Adds
import TimelessPhoenix.Routerto your Phoenix router -
Adds
timeless_phoenix_dashboard "/dashboard"to your browser scope -
Removes the default
live_dashboardroute (avoids live_session conflict) -
Updates
.formatter.exs
By default, metrics, logs, and traces are all persisted to disk under
priv/observability. If you want logs and traces to stay in memory only for
CI or ephemeral demo environments:
mix igniter.install timeless_phoenix --storage memoryHTTP Endpoints
To expose HTTP ingest/query endpoints for external tooling (Grafana, curl, etc.),
use the --http flag to enable all three:
mix igniter.install timeless_phoenix --httpOr enable them individually:
mix igniter.install timeless_phoenix --http-metrics --http-logsDefault ports are 8428 (metrics), 9428 (logs), and 10428 (traces). Override with:
mix igniter.install timeless_phoenix --http --metrics-port 9090 --logs-port 3100 --traces-port 4318| Flag | Description |
|---|---|
--http | Enable all HTTP endpoints |
--http-metrics | Enable metrics HTTP endpoint |
--http-logs | Enable logs HTTP endpoint |
--http-traces | Enable traces HTTP endpoint |
--metrics-port | Metrics port (default 8428) |
--logs-port | Logs port (default 9428) |
--traces-port | Traces port (default 10428) |
Manual
Add the dependency to mix.exs:
{:timeless_phoenix, "~> 1.5"}
Add to your application’s supervision tree (lib/my_app/application.ex):
children = [
# ... existing children ...
{TimelessPhoenix, data_dir: "priv/observability"}
]
Add to your router (lib/my_app_web/router.ex):
import TimelessPhoenix.Router
scope "/" do
pipe_through :browser
timeless_phoenix_dashboard "/dashboard"
end
Configure OpenTelemetry to export spans (config/config.exs):
config :opentelemetry, traces_exporter: {TimelessTraces.Exporter, []}
Remove the default live_dashboard route from your router — it’s
typically inside an if Application.compile_env(:my_app, :dev_routes)
block. TimelessPhoenix provides its own dashboard at the same path, and
having both causes a live_session conflict.
Add :timeless_phoenix to your .formatter.exs import_deps:
[import_deps: [:timeless_phoenix, ...]]Configuration
Child spec options
| Option | Default | Description |
|---|---|---|
:data_dir | required |
Base directory; creates metrics/, logs/, spans/ subdirs |
:name | :default | Instance name for process naming |
:metrics | DefaultMetrics.all() | Telemetry.Metrics list for the reporter |
:timeless | [] | Extra opts forwarded to TimelessMetrics |
:timeless_logs | [] | Application env overrides for TimelessLogs |
:timeless_traces | [] | Application env overrides for TimelessTraces |
:reporter | [] |
Extra opts for Reporter (:flush_interval, :prefix) |
Router macro options
timeless_phoenix_dashboard "/dashboard",
name: :default, # TimelessPhoenix instance name
metrics: MyApp.Telemetry, # custom metrics module
download_path: "/timeless/downloads", # backup download path
live_dashboard: [csp_nonce_assign_key: :csp] # extra LiveDashboard optsManual LiveDashboard setup
If you need full control over the LiveDashboard configuration instead of using the macro:
import Phoenix.LiveDashboard.Router
forward "/timeless/downloads", TimelessMetricsDashboard.DownloadPlug,
store: :tp_default_timeless
live_dashboard "/dashboard",
metrics: MyApp.Telemetry,
metrics_history: {TimelessPhoenix, :metrics_history, []},
additional_pages: TimelessPhoenix.dashboard_pages()Running in Production
The Igniter installer places timeless_phoenix_dashboard in a top-level
browser scope so it’s available in all environments. To restrict access in
production, add authentication.
Authentication
Pipeline-based auth (recommended)
Create an admin pipeline with your existing auth plugs:
pipeline :admin do
plug :fetch_current_user
plug :require_admin_user
end
scope "/" do
pipe_through [:browser, :admin]
timeless_phoenix_dashboard "/dashboard"
endBasic HTTP auth
For a quick setup using environment variables:
pipeline :dashboard_auth do
plug :admin_basic_auth
end
scope "/" do
pipe_through [:browser, :dashboard_auth]
timeless_phoenix_dashboard "/dashboard"
end
# In your router or a plug module:
defp admin_basic_auth(conn, _opts) do
username = System.fetch_env!("DASHBOARD_USER")
password = System.fetch_env!("DASHBOARD_PASS")
Plug.BasicAuth.basic_auth(conn, username: username, password: password)
endLiveView on_mount hook
For LiveView-level auth, pass on_mount through to LiveDashboard:
timeless_phoenix_dashboard "/dashboard",
live_dashboard: [on_mount: [{MyAppWeb.AdminAuth, :ensure_admin, []}]]WebSocket proxies
If your app is behind nginx or a reverse proxy, ensure WebSocket upgrades are allowed. LiveDashboard uses LiveView, which requires a WebSocket connection.
Nginx example:
location /dashboard {
proxy_pass http://localhost:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}Production data directory
In production, use a persistent path outside the release:
{TimelessPhoenix, data_dir: "/var/lib/my_app/observability"}Or configure at runtime:
{TimelessPhoenix, data_dir: System.get_env("OBS_DATA_DIR", "/var/lib/my_app/observability")}Data Retention
TimelessPhoenix ships with sensible defaults for embedded use. All three engines retain 7 days of data by default.
| Engine | Default Retention | Size Limit |
|---|---|---|
| Metrics (raw) | 7 days | none |
| Metrics (daily rollup) | 90 days | none |
| Logs | 7 days | none |
| Traces | 7 days | none |
Customizing retention
Override via the :timeless_logs and :timeless_traces child spec options:
{TimelessPhoenix,
data_dir: "priv/observability",
timeless_logs: [
retention_max_age: 30 * 86_400, # 30 days
retention_max_size: 1_073_741_824, # 1 GB cap (nil = unlimited)
retention_check_interval: 120_000 # check every 2 minutes
],
timeless_traces: [
retention_max_age: 14 * 86_400, # 14 days
retention_max_size: 512 * 1_048_576 # 512 MB cap
]}
For metrics, use the :timeless key:
{TimelessPhoenix,
data_dir: "priv/observability",
timeless: [
raw_retention_seconds: 14 * 86_400, # 14 days raw
daily_retention_seconds: 180 * 86_400 # 180 days rolled up
]}
Setting retention_max_age to nil disables time-based retention.
Setting retention_max_size to nil disables size-based retention (default).
Custom Metrics
The default metrics include VM, Phoenix, LiveView, TimelessMetrics, TimelessLogs, and TimelessTraces telemetry. To add your own:
defmodule MyApp.Telemetry do
import Telemetry.Metrics
def metrics do
TimelessPhoenix.DefaultMetrics.all() ++ [
counter("my_app.orders.created"),
summary("my_app.checkout.duration", unit: {:native, :millisecond}),
last_value("my_app.queue.depth")
]
end
endThen pass it to both the child spec and router:
# application.ex
{TimelessPhoenix, data_dir: "priv/observability", metrics: MyApp.Telemetry.metrics()}
# router.ex
timeless_phoenix_dashboard "/dashboard", metrics: MyApp.Telemetry