Skip to content
varyformPublic

About

Cookieless visitor, page view and download counts for a Rack app, in a SQLite file.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

footfall

Cookieless visitor, page view and download counts for a Rack app, in a SQLite file. Footfall is what a shop counts at its door: people coming in, not who they are.

  • Pages, visitors, referrers and downloads. A page view is a GET of HTML (or an Atom or RSS feed) answered with 200; a download, a GET under /download/ that sends the whole file, counted once even when it's resumed in pieces.
  • Nobody can be picked out. A visitor is a hash of their address and browser, salted anew every day; the salt is forgotten the next day, so a day's visitors can be counted but not followed from day to day, or traced back. No cookies, no IP addresses, and only the site a visitor came from, not the full referrer.
  • Bots left out: crawlers, link previews, monitors and command-line tools.
  • One file, no service. SQLite in WAL mode, next to your app; a report for the terminal.
  • Never breaks a page. A counting error is logged and the response goes out as it was.

Install

# Gemfile
gem "footfall"

Count

# config.ru
require "footfall"

# Outermost, so it sees every response, downloads too. Without a path it counts nothing,
# which suits development and tests.
use Footfall::Middleware, path: ENV["FOOTFALL_PATH"]

Options:

Option Default
path: (required) The SQLite file, made with its folder; nil turns counting off.
downloads: "/download/" The path prefix of files counted as downloads; nil for none.
host: the request's host This site, whose own links aren't referrers (www. is ignored).
client_ip_header: none The header a proxy puts the visitor's address in, such as Cloudflare's "CF-Connecting-IP". Only behind such a proxy: anyone can send it.
bots: Footfall::Middleware::BOTS A regexp of user agents left out.

In Rails, config.middleware.insert_before 0, Footfall::Middleware, path: ….

Report

require "footfall"

Footfall::Report.new("storage/footfall.sqlite3",
  title: "example.com",
  downloads: { "App downloads" => "%.dmg", "Plugin installs" => "Plugin-%" }
).print(days: 30)
example.com, the last 30 days (since 2026-09-10); bots left out

Totals
  Visitors (a day each)  Page views  App downloads  Plugin installs
  ---------------------  ----------  -------------  ---------------
                    412         903             57               12

By day
  Day         Visitors  Views  App downloads
  …

Then the busiest pages, the sites visitors came from, every download, and the feed's readers on the latest day. downloads: names a column in the totals per kind of file, as SQL LIKE patterns of the file names; the first is also the day by day column. Feeds (feeds:, "%/feed.xml" by default) are counted apart from pages, since readers poll them.

A small script makes it a command, for example for kamal app exec:

#!/usr/bin/env ruby
require "bundler/setup"
require "footfall"

Footfall::Report.new(ENV.fetch("FOOTFALL_PATH"), title: "example.com")
  .print(days: Integer(ARGV.first || 30))

The data

Three tables, for queries of your own:

  • views: day, path, referrer (a host, or ""), visitor, count, one row per day, page, referrer and visitor;
  • downloads: day, file, visitor, count;
  • salts: today's salt only.

Footfall only creates tables that aren't there and never alters existing ones; give it a file of its own. Days are in the server's time zone. Visitors are only comparable within a day: count DISTINCT day || visitor for "visitors, a day each".

License

MIT

About

Cookieless visitor, page view and download counts for a Rack app, in a SQLite file.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages