Lyt

Hex.pmDocs

Highly customizable analytics for Phoenix LiveView applications.

Lyt provides automatic tracking of page views and custom events in Phoenix LiveView applications. It captures session data including browser information, UTM parameters, and custom metadata.

Features

Installation

Add lyt to your list of dependencies in mix.exs:

def deps do
  [
    {:lyt, "~> 0.1.0"},
    # Include your database adapter (one of the following):
    {:postgrex, ">= 0.0.0"},     # for PostgreSQL
    {:myxql, ">= 0.0.0"},        # for MySQL
    {:ecto_sqlite3, ">= 0.0.0"}, # for SQLite3
    {:ecto_duckdb, ">= 0.0.0"}   # for DuckDB
  ]
end

Setup

1. Configure the Repository

Tell Lyt which Ecto repository to use:

# config/config.exs
config :lyt, :repo, MyApp.Repo

2. Run Migrations

Create a migration to set up the analytics tables:

mix ecto.gen.migration create_analytics_tables

Then edit the generated migration file:

defmodule MyApp.Repo.Migrations.CreateAnalyticsTables do
  use Ecto.Migration

  def up do
    Lyt.Migration.up()
  end

  def down do
    Lyt.Migration.down()
  end
end

Run the migration:

mix ecto.migrate

3. Add to Supervision Tree

Add the Lyt supervisor to your application:

# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    Lyt.Telemetry,  # Add this line
    MyAppWeb.Endpoint
  ]

  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  Supervisor.start_link(children, opts)
end

4. Add the Plug

Add Lyt.Plug to your router pipeline:

# lib/my_app_web/router.ex
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug Lyt.Plug  # Add this line
  plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers
end

That's it! Lyt will now automatically track:

Tracking Custom Events

Using the @analytics Decorator

To track specific LiveView events, use the @analytics decorator:

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view
  use Lyt

  @analytics true
  def handle_event("submit_form", params, socket) do
    # Your event handling code
    {:noreply, socket}
  end
end

Custom Event Names and Metadata

You can customize the event name and add metadata:

@analytics name: "Contact Form Submitted", metadata: %{"form_type" => "contact"}
def handle_event("submit", params, socket) do
  # ...
  {:noreply, socket}
end

Or use a function to generate metadata dynamically:

@analytics name: "Item Purchased", metadata: &extract_purchase_metadata/1
def handle_event("purchase", params, socket) do
  # ...
  {:noreply, socket}
end

defp extract_purchase_metadata(params) do
  %{"item_id" => params["id"], "quantity" => params["qty"]}
end

Module-Level Tracking Options

Configure tracking at the module level:

# Track all events automatically
use Lyt, track_all: true

# Track all events except specific ones
use Lyt, track_all: true, exclude: ["ping", "heartbeat"]

# Only track specific events (without needing @analytics)
use Lyt, include: ["submit_form", "click_button"]

Before-Save Callbacks

Filter or modify events before they're saved:

use Lyt, before_save: &__MODULE__.filter_analytics/3

def filter_analytics(changeset, opts, socket) do
  # Skip tracking for admin users
  if socket.assigns.current_user.admin? do
    :halt
  else
    {:ok, changeset}
  end
end

You can also set before_save at the decorator level:

@analytics before_save: &__MODULE__.add_user_info/3
def handle_event("action", params, socket) do
  # ...
end

defp add_user_info(changeset, _opts, socket) do
  metadata = Ecto.Changeset.get_field(changeset, :metadata) || %{}
  updated = Map.put(metadata, "user_id", socket.assigns.current_user.id)
  {:ok, Ecto.Changeset.put_change(changeset, :metadata, updated)}
end

JavaScript API

Lyt provides a REST API for tracking events from JavaScript. This is useful for:

Setup

Add the API router to your Phoenix router:

# lib/my_app_web/router.ex
forward "/api/analytics", Lyt.API.Router

That's it! No additional configuration required.

How It Works

Sessions are derived automatically from request data (user agent, IP address, hostname), so JavaScript can fire events immediately without waiting for a session to be created. The same browser/IP combination will always map to the same session.

Tracking Events

Single Event

fetch('/api/analytics/event', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({
    name: 'Button Click',
    path: '/dashboard',
    metadata: {button_id: 'signup', variant: 'blue'}
  })
});

Batch Events

Send multiple events in a single request (up to 100 by default):

fetch('/api/analytics/events', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({
    events: [
      {name: 'Page View', path: '/home'},
      {name: 'Scroll Depth', metadata: {depth: 50}},
      {name: 'Time on Page', metadata: {seconds: 30}}
    ]
  })
});

Request Fields

Field Required Description
name Yes Event name (e.g., "Button Click", "Page View")
path No Page path (defaults to "/")
hostname No Hostname (defaults to request host)
metadata No Custom data object (max 10KB)
screen_width No Screen width in pixels (captured on session)
screen_height No Screen height in pixels (captured on session)
utm_source No UTM source parameter
utm_medium No UTM medium parameter
utm_campaign No UTM campaign parameter
utm_term No UTM term parameter
utm_content No UTM content parameter

Response Format

Success:

{"ok": true}

Success (batch):

{"ok": true, "queued": 3}

Validation error:

{
  "ok": false,
  "error": "validation_error",
  "details": {"name": ["is required"]}
}

Example: Track Page Views and Interactions

// Track initial page view with screen dimensions
fetch('/api/analytics/event', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({
    name: 'Page View',
    path: window.location.pathname,
    screen_width: window.innerWidth,
    screen_height: window.innerHeight
  })
});

// Track button clicks
document.querySelectorAll('[data-track]').forEach(el => {
  el.addEventListener('click', () => {
    fetch('/api/analytics/event', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({
        name: el.dataset.track,
        path: window.location.pathname,
        metadata: {element_id: el.id}
      })
    });
  });
});

API Configuration

# config/config.exs
config :lyt, Lyt.API.Router,
  max_batch_size: 100,        # Maximum events per batch request
  max_metadata_size: 10_240,  # Maximum metadata size in bytes (10KB)
  max_name_length: 255,       # Maximum event name length
  before_save: &MyModule.filter/2  # Optional callback to filter events

CORS

The API router does not handle CORS. If you need cross-origin requests, configure CORS in your Phoenix pipeline or use a library like cors_plug:

# lib/my_app_web/router.ex
pipeline :api do
  plug :accepts, ["json"]
  plug CORSPlug, origin: ["https://myapp.com"]
end

scope "/api" do
  pipe_through :api
  forward "/analytics", Lyt.API.Router
end

JavaScript SDK

For a simpler integration, Lyt provides a JavaScript SDK that handles automatic pageview tracking, SPA navigation, and provides a clean API for custom events.

Events are queued locally and sent in batches (default: every 1 second) to minimize network requests. The queue is automatically flushed when the user navigates away or the tab becomes hidden.

Installation

Copy priv/static/lyt.js (or lyt.min.js for production) to your Phoenix static assets:

cp deps/lyt/priv/static/lyt.min.js priv/static/js/

Add to your layout:

<script defer data-api="/api/analytics" src="/js/lyt.min.js"></script>

The SDK will automatically track pageviews, including SPA navigation.

Script Tag Options

Configure via data attributes:

Attribute Default Description
data-api/api/analytics API endpoint path
data-autotrue Auto-track pageviews
data-spatrue Track SPA navigation (history API)
data-hashfalse Track hash-based routing
data-interval1000 Queue flush interval in ms
data-debugfalse Enable console logging

Example with options:

<script defer 
        data-api="/api/analytics" 
        data-hash="true"
        data-debug="true"
        src="/js/lyt.min.js"></script>

Tracking Custom Events

// Basic event
lyt(&#39;Button Click&#39;)

// Event with metadata
lyt(&#39;Purchase&#39;, {
  metadata: {
    product_id: &#39;123&#39;,
    price: 29.99
  }
})

// Event with custom path
lyt(&#39;Virtual Page&#39;, { path: &#39;/onboarding/step-2&#39; })

// Event with callback
lyt(&#39;Form Submit&#39;, { metadata: { form: &#39;contact&#39; } }, function(response) {
  if (response.ok) {
    console.log(&#39;Event tracked!&#39;)
  }
})

Batch Events

Send multiple events in one request:

lyt.batch([
  { name: &#39;Page View&#39;, path: &#39;/checkout&#39; },
  { name: &#39;Cart Items&#39;, metadata: { count: 3 } },
  { name: &#39;Total&#39;, metadata: { amount: 99.99 } }
], function(response) {
  console.log(&#39;Queued:&#39;, response.queued)
})

Manual Pageview Tracking

If you disable auto-tracking (data-auto="false"), track pageviews manually:

// Track current page
lyt.pageview()

// Track virtual page
lyt.pageview({ path: &#39;/virtual/page&#39; })

Runtime Configuration

lyt.configure({
  endpoint: &#39;/custom/analytics&#39;,
  debug: true,
  autoPageview: false,
  spaMode: true,
  hashRouting: false,
  flushInterval: 2000  // Flush every 2 seconds
})

Manual Queue Control

// Flush the queue immediately (e.g., before a critical action)
lyt.flush(function(response) {
  console.log(&#39;Flushed:&#39;, response.queued, &#39;events&#39;)
})

// Check queue length
console.log(&#39;Pending events:&#39;, lyt.queueLength())

Privacy Controls

// Opt out of tracking (persists to localStorage)
lyt.optOut()

// Opt back in
lyt.optIn()

Queue Pattern (Pre-initialization)

Track events before the script loads:

<script>
  window.lyt = window.lyt || function() {
    (lyt.q = lyt.q || []).push(arguments)
  }
  
  // These will be sent once the SDK loads
  lyt(&#39;Early Event&#39;)
</script>
<script defer src="/js/lyt.min.js"></script>

Automatic Behaviors

Filtering - The SDK automatically skips tracking for:

Auto-flush - The queue is automatically flushed:

Configuration Options

All configuration is optional. Here are the available options:

# config/config.exs

# Required: Your Ecto repository
config :lyt, :repo, MyApp.Repo

# Session cookie name (default: "lyt_session")
config :lyt, :session_cookie_name, "my_analytics_session"

# Session length in seconds (default: 300)
config :lyt, :session_length, 600

# Session cookie options (all optional)
config :lyt, :session_cookie_opts,
  same_site: "Strict",    # "Strict", "Lax", or "None" (default: "Lax")
  secure: true,           # Require HTTPS (default: false)
  http_only: true,        # Not accessible via JavaScript (default: true)
  domain: ".example.com"  # Cookie domain (default: not set)

# Custom salt for session ID derivation (recommended for production)
config :lyt, :session_salt, "your-secret-random-salt"

# Paths to exclude from tracking (default: [])
config :lyt, :excluded_paths, ["/health", "/metrics", "/api"]

# Enable synchronous mode for testing (default: false)
config :lyt, :sync_mode, false

# Event queue configuration
config :lyt, Lyt.EventQueue,
  flush_interval: 100,       # ms between batch inserts
  batch_size: 50,            # max items per batch
  max_session_cache: 10_000  # max inserted sessions to keep in memory

Test Configuration

For testing, enable synchronous mode to avoid async timing issues:

# config/test.exs
config :lyt, :sync_mode, true

Database Schema

Lyt creates the following tables:

lyt_sessions

Column Type Description
id string Primary key (64-char hex)
user_id string Optional user identifier
hostname string Request hostname
entry string First page visited
exit string Last page visited
referrer string HTTP referrer
started_at datetime Session start time
ended_at datetime Session end time
screen_width integer Screen width (if provided)
screen_height integer Screen height (if provided)
browser string Browser name
browser_version string Browser version
operating_system string OS name
operating_system_version string OS version
utm_source string UTM source
utm_medium string UTM medium
utm_campaign string UTM campaign
utm_term string UTM term
utm_content string UTM content
metadata map Custom metadata

lyt_events

Column Type Description
id integer Primary key (auto-increment)
session_id string Foreign key to sessions
name string Event name
path string Page path
query string Query string
hostname string Request hostname
metadata map Custom event metadata

Querying Analytics Data

Query your analytics data using Ecto:

import Ecto.Query

# Get all sessions from the last 24 hours
from(s in Lyt.Session,
  where: s.inserted_at > ago(24, "hour"),
  order_by: [desc: s.inserted_at]
)
|> MyApp.Repo.all()

# Count events by name
from(e in Lyt.Event,
  group_by: e.name,
  select: {e.name, count(e.id)}
)
|> MyApp.Repo.all()

# Get page views with session info
from(e in Lyt.Event,
  join: s in Lyt.Session, on: e.session_id == s.id,
  where: e.name == "Page View",
  select: %{path: e.path, browser: s.browser, utm_source: s.utm_source}
)
|> MyApp.Repo.all()

How It Works

Session Tracking

  1. When a request comes in, Lyt.Plug checks for an existing session cookie
  2. If no session exists, a new one is created with:
    • A deterministically derived 64-character ID
    • Parsed user-agent information (browser, OS)
    • UTM parameters from the query string
  3. The session ID is stored in a cookie and passed to LiveView via the session

Session ID Derivation

Lyt uses deterministic session IDs derived from request data, which enables JavaScript clients to fire events immediately without waiting for session creation. The session ID is a SHA-256 hash of:

Security Considerations:

For use cases requiring stronger session isolation, consider:

  1. Setting a cryptographically random salt per deployment
  2. Adding additional entropy via custom session attributes
  3. Using the user_id field to associate sessions with authenticated users

Event Tracking

  1. For regular requests, Lyt.Plug records a "Page View" event
  2. For LiveView:
    • Mount events create a "Live View" event
    • Navigation (handle_params) creates events when the path changes
    • Custom events are tracked via the @analytics decorator
  3. Events are queued asynchronously and batch-inserted for performance

Performance

License

MIT License. See LICENSE for details.