Runlight counts who visits your site and where they came from, from inside your own app, with every number kept in your own database.
Runlight is MIT licensed and takes one package and one route to set up, with a script under 2 KB gzipped. It sets no cookies and stores nothing personal.
npm install @runlight/sdk better-sqlite3
pnpm add @runlight/sdk better-sqlite3
yarn add @runlight/sdk better-sqlite3
bun add @runlight/sdk better-sqlite3
composer require runlight/runlight
Laravel, Symfony, and plain PHP apps on PHP 8.2 or later each take a few lines, which the PHP docs show.
pip install runlight
Django, Flask, FastAPI, and any other WSGI or ASGI app on Python 3.11 or later take a few lines each, which the Python docs show.
bundle add runlight
A Rails app then runs bin/rails generate runlight:install, as the Rails docs show. Sinatra, Hanami, Roda, and other Rack apps on Ruby 3.2 or later add a database driver such as sqlite3, as the Ruby docs show.
go get runlight.sh/go modernc.org/sqlite
Any net/http app, chi, or Echo on Go 1.25 or later takes a few lines, and any database/sql driver can stand in for SQLite, as the Go docs show.
implementation("sh.runlight:runlight:0.3.2")
That line is for Gradle, and Maven takes the same coordinates. The JDK’s own server, servlet containers, and Spring Boot on Java 21 or later each take a few lines, which the Java docs show.
dotnet add package Runlight.AspNetCore
An ASP.NET Core app on .NET 10 or later adds a driver such as Microsoft.Data.Sqlite and calls app.MapRunlight(), as the .NET docs show.
{:runlight, "~> 0.3"}
Add that line to the deps in mix.exs. A Phoenix or Plug app on Elixir 1.18 or later keeps Runlight’s tables in its own Ecto repo, as the Elixir docs show.
cargo add runlight runlight-sqlx --features runlight/axum,runlight-sqlx/sqlite
Runlight runs on tokio behind axum, hyper, or any server that takes a tower service, as the Rust docs show.
docker run -p 3000:3000 -v runlight:/data ghcr.io/runlightsh/runlight
npx @runlight/server
composer require runlight/runlight && cp -r vendor/runlight/runlight/standalone public
Run it in a new folder beside your web root, point a domain at public, and add one cron line, as the PHP docs show. It needs only PHP, no Node.
pip install runlight && runlight
The runlight command starts the server on port 3000 and prints a link to make your account, as the Python docs show.
bundle add runlight sqlite3 puma && bundle exec runlight serve
Run it in a new folder after bundle init, as the Ruby docs show.
go install runlight.sh/go/cmd/runlight@latest
Then run runlight, which starts the server on port 3000, as the Go docs show.
java -cp runlight.jar:sqlite-jdbc.jar sh.runlight.server.Cli
Download the runlight jar and your database’s JDBC driver from Maven Central first, as the Java docs show.
dotnet tool install --global Runlight.Server
Then run runlight serve in an empty folder, as the .NET docs show.
wp plugin install https://github.com/runlightsh/runlight/releases/latest/download/runlight.zip --activate
You can also download the zip and upload it under Plugins, Add New in WordPress.
composer require drupal/runlight
Composer brings the Runlight library the module needs to run Runlight inside Drupal. Then enable it under Extend or with drush pm:install runlight.
composer require runlight/craft
Then run php craft plugin/install runlight, or install it from the Plugin Store in your Craft control panel.
Paste it into any coding agent to set Runlight up. The same prompt is at /prompt.txt.
Dashboard
Everything fits on one page.
The dashboard mounts inside your app at /runlight. Its first line says how many people came and how that compares with before. Six numbers sit above a chart, and below them are the pages, sources, places, devices, campaigns, and AI agents, where one click on any of them filters everything else.
This is the dashboard as the package serves it, showing a month of a demo blog.
Install
Setup takes three files in the app you already have.
One file says where the numbers live, and a script tag in your layout counts every page. Between them sits one route that serves everything under /runlight, from the script that collects visits to the dashboard that shows them. Because the script posts to your own domain, ad blockers have nothing to block.
lib/runlight.ts
import { runlight } from "@runlight/sdk";
import { sqlite } from "@runlight/sdk/sqlite"; // or postgres
export const rl = runlight({
store: sqlite({ path: "./data/runlight.db" }),
site: { hostnames: ["example.com"], timezone: "Europe/London" },
});app/runlight/[[...path]]/route.ts
import { rl } from "@/lib/runlight";
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = rl.routes();app/layout.tsx
<script defer src="/runlight/s.js"></script>That example is Next.js. Nuxt, SvelteKit, Astro, Remix, Express, NestJS, Fastify, Koa, Hono, Bun, Deno, and Cloudflare Workers take a few lines each, and the docs have the steps for every framework. Runlight is also written for PHP, Python, Ruby, Go, Java, .NET, Elixir, and Rust, so a Laravel, Django, Rails, Phoenix, Spring Boot, or ASP.NET Core app gets the same dashboard from a package in its own language. Set RUNLIGHT_TOKEN and open /runlight to sign in.
Privacy
It counts people without knowing who they are.
Each day, at midnight in your site’s timezone, Runlight makes a new random salt. A visitor is a hash of that salt, your site, their IP address, and their browser’s user agent, cut to 64 bits. The address and the user agent are never written down, and a salt is deleted once its day is over, or once a visit running past midnight has ended, so from then on nobody, you included, can tell whether two visits came from the same person.
Runlight sets no cookies and stores nothing in your visitors’ browsers, so there is nothing to put in a banner. The one thing the script ever writes to local storage is an opt-out flag you can set in your own browser, so your visits are not counted.
- visitor
- a6fbd78a579b03bf
- arrived
- 2026-10-06 18:29 UTC, on /blog/building-runlight
- stayed
- 3 pageviews, 4m 27s engaged, left from /
- came from
- Hacker News (Social), news.ycombinator.com
- place
- New York, US-NY, US
- device
- desktop, Windows 10, Edge 129, 1920x1080, en-US
This is everything Runlight keeps about one visit from the demo. It never keeps the IP address or the user agent, and no identifier it keeps lasts more than two days. A visitor lasts one day, or until a visit that runs past midnight ends. The privacy docs explain how it works.
Sources
It knows when a visit came from ChatGPT.
Runlight turns referrers into sources you recognise and groups those sources into channels such as search, social, email, campaigns, and AI. The AI channel holds people who clicked through from ChatGPT, Claude, Perplexity, Gemini, and the rest. Clicks that pass through newsletter trackers or webmail count as email.
AI agents that fetch your pages to answer a question never run JavaScript, so no tracker sees them. Runlight counts them with one line of middleware, rl.observe(request).




Live
The live view shows what is happening right now.
Click “here now” for the last half hour minute by minute, the pages people are on, where they came from, and a running line of what they did. It refreshes every ten seconds.


Goals
Runlight counts conversions and what they were worth.
A goal can be an event you send or a page someone reaches (/thanks* works). It can also be a click on something, which needs no code. After you press “Pick on my site” and click the button on your own page, Runlight counts it from then on.
Give a goal a fixed value or read the amount from the event, and the box shows revenue for each goal, broken down by source and page.
Goals are worked out when you read them, so an event or page goal you add today counts last month too.


Links
Short links on your own domain are counted like visits.
Make t.example.com/sale from the dashboard and every click is counted with its source, country, device, and campaign. Runlight handles thousands of links. Bring yours over from Umami, Dub, Bitly, Short.io, Rebrandly, or a CSV, with their click history where the service shares it.


Reports
The weekly email reads like the dashboard.
Pick who gets a weekly or monthly summary, each in their own language. It goes out from your own address through the email service you already use, and every email has a one-click unsubscribe. Keys are stored encrypted and never shown again.


This is the weekly email for the demo site, which turns light or dark to match the reader’s mail app.
Also
Runlight also covers the rest of what you would expect.
- Sharing
- A private, read-only link opens one site’s dashboard, and it stops working when you delete it.
- Compare
- You can compare a range with the period before it or the same days a year earlier, and you can pick the comparison dates yourself.
- Filters
- You can combine filters on page, source, campaign, country, city, device, browser, and more.
- Languages
- The dashboard and the emails come in English, French, Spanish, German, and Portuguese.
- Sites
- One install can count several sites, told apart by hostname.
- Stores
- On a server Runlight uses SQLite, Postgres, MySQL, MariaDB, or Bun’s own SQLite, and on the edge it uses Turso or Cloudflare D1.
- API
- Everything on the dashboard is available as JSON at
/runlight/api. - Ask your AI
- Claude, Cursor, and other apps that speak MCP can answer questions about your stats with a read-only token. See how to connect one.
Why
Your analytics can live in the app that serves your pages.
Runlight does its counting in the app you already run and keeps the numbers in the database you already back up. You learn which pages people read and where they came from while every visit stays on your own servers, and the cost stays the same however much your traffic grows.
It never keeps anything that could say who a visitor was, so you have no cookie banner to add and no third party to trust with your visitors. The data is yours from the first visit, and it stays in a database you can query or delete whenever you like.
Count your first visit in about five minutes.