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.
- Automatic LiveView Tracking - Tracks mounts and navigation without manual instrumentation
- Custom Event Tracking - Use the
@analyticsdecorator to track specific events - Session Management - Automatic session creation with device/browser detection
- UTM Parameter Capture - Automatically captures marketing attribution data
- Async Event Queuing - High-performance batch inserts via GenServer
- Multi-Database Support - Works with PostgreSQL, MySQL, SQLite3, and DuckDB
- Flexible Configuration - Include/exclude events, custom callbacks, and more
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
]
endTell Lyt which Ecto repository to use:
# config/config.exs
config :lyt, :repo, MyApp.RepoCreate a migration to set up the analytics tables:
mix ecto.gen.migration create_analytics_tablesThen 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
endRun the migration:
mix ecto.migrateAdd 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)
endAdd 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
endThat's it! Lyt will now automatically track:
- Page views for regular (non-LiveView) requests
- LiveView mounts and navigation
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
endYou 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}
endOr 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"]}
endConfigure 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"]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
endYou 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)}
endLyt provides a REST API for tracking events from JavaScript. This is useful for:
- Single-page applications that don't use LiveView
- Tracking client-side interactions (scroll depth, time on page, etc.)
- Mobile apps or external services
Add the API router to your Phoenix router:
# lib/my_app_web/router.ex
forward "/api/analytics", Lyt.API.RouterThat's it! No additional configuration required.
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.
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'}
})
});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}}
]
})
});| 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 |
Success:
{"ok": true}Success (batch):
{"ok": true, "queued": 3}Validation error:
{
"ok": false,
"error": "validation_error",
"details": {"name": ["is required"]}
}// 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}
})
});
});
});# 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 eventsThe 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
endFor 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.
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.
Configure via data attributes:
| Attribute | Default | Description |
|---|---|---|
data-api |
/api/analytics |
API endpoint path |
data-auto |
true |
Auto-track pageviews |
data-spa |
true |
Track SPA navigation (history API) |
data-hash |
false |
Track hash-based routing |
data-interval |
1000 |
Queue flush interval in ms |
data-debug |
false |
Enable console logging |
Example with options:
<script defer
data-api="/api/analytics"
data-hash="true"
data-debug="true"
src="/js/lyt.min.js"></script>// Basic event
lyt('Button Click')
// Event with metadata
lyt('Purchase', {
metadata: {
product_id: '123',
price: 29.99
}
})
// Event with custom path
lyt('Virtual Page', { path: '/onboarding/step-2' })
// Event with callback
lyt('Form Submit', { metadata: { form: 'contact' } }, function(response) {
if (response.ok) {
console.log('Event tracked!')
}
})Send multiple events in one request:
lyt.batch([
{ name: 'Page View', path: '/checkout' },
{ name: 'Cart Items', metadata: { count: 3 } },
{ name: 'Total', metadata: { amount: 99.99 } }
], function(response) {
console.log('Queued:', response.queued)
})If you disable auto-tracking (data-auto="false"), track pageviews manually:
// Track current page
lyt.pageview()
// Track virtual page
lyt.pageview({ path: '/virtual/page' })lyt.configure({
endpoint: '/custom/analytics',
debug: true,
autoPageview: false,
spaMode: true,
hashRouting: false,
flushInterval: 2000 // Flush every 2 seconds
})// Flush the queue immediately (e.g., before a critical action)
lyt.flush(function(response) {
console.log('Flushed:', response.queued, 'events')
})
// Check queue length
console.log('Pending events:', lyt.queueLength())// Opt out of tracking (persists to localStorage)
lyt.optOut()
// Opt back in
lyt.optIn()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('Early Event')
</script>
<script defer src="/js/lyt.min.js"></script>Filtering - The SDK automatically skips tracking for:
- Local file protocol (
file://) - Automated testing (Cypress, Phantom, Nightmare, WebDriver)
- Users who called
lyt.optOut()
Auto-flush - The queue is automatically flushed:
- Every 1 second (configurable via
data-interval) - When the page is hidden (tab switch, minimize)
- When the user navigates away (
pagehideevent)
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 memoryFor testing, enable synchronous mode to avoid async timing issues:
# config/test.exs
config :lyt, :sync_mode, trueLyt creates the following tables:
| 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 |
| 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 |
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()- When a request comes in,
Lyt.Plugchecks for an existing session cookie - 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
- The session ID is stored in a cookie and passed to LiveView via the session
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:
- A configurable salt (defaults to a hash of the node name)
- User-Agent header
- Remote IP address
- Request hostname
Security Considerations:
-
Salt configuration: The default salt is derived from the node name. For production deployments, configure a custom salt:
config :lyt, :session_salt, "your-secret-random-salt"
-
User agent spoofing: User agents can be easily spoofed by clients. This means a malicious actor could potentially generate the same session ID as another user if they know (or guess) the other inputs.
-
Shared IP addresses: Users behind NAT, VPNs, or corporate proxies may share IP addresses. Combined with similar user agents, this could result in session collisions.
-
Privacy: The session ID derivation does not include any personally identifiable information beyond what's already visible in server logs (IP, user agent).
For use cases requiring stronger session isolation, consider:
- Setting a cryptographically random salt per deployment
- Adding additional entropy via custom session attributes
- Using the
user_idfield to associate sessions with authenticated users
- For regular requests,
Lyt.Plugrecords a "Page View" event - For LiveView:
- Mount events create a "Live View" event
- Navigation (handle_params) creates events when the path changes
- Custom events are tracked via the
@analyticsdecorator
- Events are queued asynchronously and batch-inserted for performance
- Events are queued in a GenServer and batch-inserted periodically
- Default: 50 items per batch, every 100ms
- Sessions are always inserted before their events (foreign key safety)
- Use
sync_mode: truein tests for deterministic behavior
MIT License. See LICENSE for details.