Timeless

Unified Observability for Phoenix

Hex.pm Docs License


"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

Installation

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_phoenix

This automatically:

  1. Adds {TimelessPhoenix, ...} to your supervision tree
  2. Configures OpenTelemetry to export spans to TimelessTraces
  3. Adds import TimelessPhoenix.Router to your Phoenix router
  4. Adds timeless_phoenix_dashboard "/dashboard" to your browser scope
  5. Removes the default live_dashboard route (avoids live_session conflict)
  6. 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 memory

HTTP 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 --http

Or enable them individually:

mix igniter.install timeless_phoenix --http-metrics --http-logs

Default 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
FlagDescription
--httpEnable all HTTP endpoints
--http-metricsEnable metrics HTTP endpoint
--http-logsEnable logs HTTP endpoint
--http-tracesEnable traces HTTP endpoint
--metrics-portMetrics port (default 8428)
--logs-portLogs port (default 9428)
--traces-portTraces 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

OptionDefaultDescription
:data_dirrequiredBase directory; creates metrics/, logs/, spans/ subdirs
:name:defaultInstance name for process naming
:metricsDefaultMetrics.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 opts

Manual 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"
end

Basic 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)
end

LiveView 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.

EngineDefault RetentionSize Limit
Metrics (raw)7 daysnone
Metrics (daily rollup)90 daysnone
Logs7 daysnone
Traces7 daysnone

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
end

Then 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