Page Views Without a Tracking Script
Privacy-first analytics in ASP.NET Core
The question every site owner asks — is anyone reading this? — has a default answer that is oddly heavy: a JavaScript snippet from a third party, a cookie, a consent banner to make the cookie legal, and a dashboard that knows more about your readers than you wanted to. For a blog, a docs site or an internal tool the actual need is small: page views per day, per path, where they came from, and no crawlers in the count. That is a middleware, not a platform.
The blog you are reading counts its visitors that way, with Regira.Web.Analytics as the middleware, and your visit has just produced one row. The pattern matters more than the package — server-side capture, a bounded queue, a batched writer — and there is a dotnet new web-sized sample to curl along with.
Universal or Regira? Counting on the server, keeping the write off the request thread and leaving no column for an identifier is the pattern, and any middleware can follow it. The request filter, the bot detector, the queue and writer, the hooks and the stats endpoint are Regira.Web.Analytics; geolocation is the separate Regira.Web.Analytics.GeoIP2. The store is yours either way.
The row this visit produced
One PageView row per qualifying request: UTC timestamp, site name, path, query string, referrer (raw and just the host), the utm_source, the user agent, a masked client IP, a bot flag and the status code. That is the full list. Nothing in it ties two visits to the same person: no cookie, no session id, no fingerprint, no full IP address. The masking keeps a configurable prefix — 24 bits for IPv4 (drop the last octet), 48 for IPv6.
The privacy argument is therefore not "we anonymize". It is that the data model has no place to put the thing you would need to identify anyone — no column an identifier could live in. That is a smaller claim, and an easier one to defend to your readers, your legal counsel and yourself.
The wiring
dotnet add package Regira.Web.Analytics --version 6.3.2
using Regira.Web.Analytics;
using Regira.Web.Analytics.Endpoints;
var builder = WebApplication.CreateBuilder(args);
builder.AddAnalyticsConfiguration(); // optional: a watched botdetector.json
builder.Services.AddAnalytics(builder.Configuration) // the "Analytics" section
.WithStore<FilePageViewStore>(); // yours - the one required piece
var app = builder.Build();
app.UseForwardedHeaders(); // the host's job, and it must come first
app.UseAnalytics(); // before static files / SPA fallback
app.MapAnalyticsEndpoints(); // optional stats route
"Analytics": {
"SiteName": "analytics-sample",
"IgnorePaths": [ "/api", "/css", "/js" ]
}
Two of those lines are about ordering, and each has a reason. UseForwardedHeaders() comes first because behind a reverse proxy RemoteIpAddress is the proxy, and the package does not configure forwarded headers for you: the host knows which proxies to trust, the library does not. UseAnalytics() comes before static files and any SPA fallback, because those answer requests the middleware would otherwise never see.
The default filter counts a request as a page view when it is a GET, its Accept header contains text/html, its last path segment has no dot (a route rather than a file), and it is not under a built-in prefix or one of your IgnorePaths. It is recorded when the response is 200 or 304. Each of those rules is visible in a curl once the sample is running with dotnet run:
URL=http://127.0.0.1:5320 # the sample's port (launchSettings.json); use 127.0.0.1, not localhost
curl "$URL/" # served, not tracked: no text/html in Accept
curl -H "Accept: text/html" "$URL/" # tracked
curl -H "Accept: text/html" "$URL/api/ping" # never: /api is ignored
After a couple of seconds:
cat App_Data/pageviews.jsonl
One JSON object per line, one line per view. For the loopback curl above, IpAddress reads 127.0.0.0 — the last octet gone — UserAgent is curl/8.x, and IsBot is true, for a reason the bots section gets to.
The store is yours
The package ships no storage, no geolocation and no extra dependencies, only a reference to Regira.Web. Where a page-view row ends up is the one thing no library can decide well — a SQLite table, a Postgres partition, a queue, a file — so persistence is one interface with one method, called off the request thread with a batch:
using System.Text.Json;
using Regira.Web.Analytics.Models;
using Regira.Web.Analytics.Services;
public class FilePageViewStore(IHostEnvironment env) : IPageViewStore<PageView>
{
public async Task SaveAsync(IReadOnlyList<PageView> views, CancellationToken cancellationToken = default)
{
var path = Path.Combine(env.ContentRootPath, "App_Data", "pageviews.jsonl");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read);
await using var writer = new StreamWriter(stream);
foreach (var view in views)
await writer.WriteLineAsync(JsonSerializer.Serialize(view, view.GetType()));
}
}
Between the middleware and this method sit the bounded queue and the batched writer: a slow store never slows a page, and a store that is down loses rows rather than requests. The store is resolved from a fresh service scope per batch, so a store that takes a DbContext in its constructor works without ceremony; this blog's store is exactly that, an EF Core table on the blog's own database provider.
Implement IPageViewStatsStore on the same class and WithStore<T>() wires it automatically; with an Analytics:ApiKey set, that lights up GET /analytics/stats, humans and bots counted apart.
Telling readers from robots
Roughly half of a small site's traffic is not people. The package's BotDetector asks two independent questions, and a visit is flagged when either says yes.
Does the agent claim to be a person? Every real browser announces a browser token — Mozilla/ or Opera/ — so a user agent that names none (curl/8.5.0, python-requests, a one-off crawler nobody has listed yet) is caught by shape. The marker list then does the job only a list can: recognize a crawler dressed as a browser — Googlebot, the AI fetchers that arrive as Mozilla/5.0 (compatible; Claude-User/1.0; +…), feed readers, the +contact-url convention crawlers follow and browsers never do.
Could a person have asked for this target? The second question reads the request rather than the agent, because it is the only signal that survives a scanner copying a real Chrome string. A path under /wp-admin on a site that isn't WordPress, a dot-directory like /.git/config, or a path climbing out of the site root — matched against path and query, so /?file=../../../../etc/passwd is caught on your own home page:
curl -H "Accept: text/html" -A "Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0" \
"$URL/?file=../../../../etc/passwd" # 200, recorded, IsBot: true
The built-in probe list stays deliberately unambiguous. Scanners also try /admin and /login, but those are somebody's real pages; flagging them is your call, via BotDetection:ProbePaths. Bots are recorded by default (RecordBots) with the flag set, so the stats can show both numbers; turn it off and they are dropped at capture.
Four setups you are likely to have
A blog or docs site behind a proxy. The wiring above, plus real forwarded-headers options:
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
o.KnownProxies.Add(IPAddress.Parse("10.0.0.5")); // or KnownNetworks for a range
});
Referrer host and utm_source are already columns, so "which newsletter, which social post, which search engine sends readers" is a GROUP BY ReferrerHost.
A database instead of a file. Stores are scoped, so a DbContext goes straight into the constructor, and implementing the retention interface on the same class gives you the daily purge for free:
using Regira.Web.Analytics.Models;
using Regira.Web.Analytics.Services;
public class EfPageViewStore(AnalyticsDbContext db) : IPageViewStore<PageView>, IPageViewRetentionStore
{
public async Task SaveAsync(IReadOnlyList<PageView> views, CancellationToken cancellationToken = default)
{
db.PageViews.AddRange(views);
await db.SaveChangesAsync(cancellationToken);
}
public Task<int> PurgeAsync(string siteName, DateTime cutoffUtc, CancellationToken cancellationToken = default)
=> db.PageViews.Where(v => v.SiteName == siteName && v.TimestampUtc < cutoffUtc)
.ExecuteDeleteAsync(cancellationToken);
}
RetentionDays (365 by default) is then a real promise you can put in a privacy statement.
A Vue or React SPA. The same wiring, with MapFallbackToFile("index.html") as the fallback the middleware has to precede; each full page load is one row for the path the browser asked for, and fetch and XHR calls are never counted because they do not ask for text/html.
Several sites, one table. SiteName is a column, so an organization's blog, docs and marketing site can share a store; each host sets its own Analytics:SiteName.
Extending without forking
Everything above the store is a hook, and one of them matters for privacy. An enricher runs in the background writer before the IP is masked, while the full address still exists — in memory only, never in a row. That is where Regira.Web.Analytics.GeoIP2 does a MaxMind GeoLite2 lookup against a local .mmdb file: addresses never leave the server, and the row gains country and city while the address is still masked before it is stored. The other hooks are in the docs.
The trade-offs
A server-side counter cannot see what a script sees: no scroll depth, no time on page, no client-side route changes in a SPA (the fallback route answers once), and no distinction between a reload and a return visit — by design, since that distinction is the identifier we refused to store. It counts requests for HTML, so a reader with a prefetching browser or a preview-generating chat client can register a view they never looked at. And it is one more piece of your own infrastructure: a store to run, a retention purge to keep the table honest.
What you get in return is a number you can explain in one sentence to anyone who asks how you count, a page that loads nothing from anyone else, and no banner.
Why not Plausible, Umami or GoatCounter? All three are cookieless and privacy-respecting, and if you want dashboards, funnels or a hosted service they are good choices. The difference is smaller and sharper than "privacy": each is still a script (or a pixel) in your page and a request to another process, a hosted service or a container you run beside the site. This is zero client bytes and in-process: the page loads nothing from anyone, sends nothing anywhere, and the row is the one described at the top. The nearest true comparison is parsing your own server logs, typed and queryable. If you need what only a script can see — scroll depth, client-side route changes, returning visitors — a script is the right tool, and the trade-offs above say so.
Docs: Regira.Web.Analytics and Regira.Web.Analytics.GeoIP2 — configuration keys, hooks, the stats endpoint. Both packages are Apache-2.0.
Versions: the sample pins Regira.Web.Analytics 6.3.2.