A daily counter can answer how many requests occurred. It cannot identify every request, recover its exact time, or explain which read path it used. That distinction became visible when a page had a nonzero AI-request total but no individual visits to list.
The system needed richer records for new traffic while preserving the meaning of older totals. Expanding one historical count into several plausible-looking rows would have produced a cleaner interface and weaker evidence. The migration therefore kept the old aggregate as an aggregate, added real event rows only when they were observed, and made the boundary visible to readers.
The information loss happened at aggregation time
The original model stored an article slug, locale, normalized agent family, UTC date, and request count. Those fields support daily totals. They do not contain an individual client name, a per-request timestamp, a detection source, or whether the client read HTML or machine-readable JSON.
No migration can recover dimensions that were never stored. A count of five could represent five requests from one detected client, one request from each of five clients, or another sequence entirely. Assigning names and times later would turn assumptions into records.
The safe boundary was straightforward:
- Existing rows stay in the daily table.
- New requests create real event rows.
- The interface labels unreconstructable data as a historical summary.
- Claims about individual visits begin only when the event schema is active.
This principle applies beyond request analytics. Any system that moves from balances, counters, or daily snapshots to an event log must preserve the difference between measured history and reconstructed narrative.
Write the total and the event together
The new write path keeps two representations because they serve different readers. The daily table remains efficient for totals and compatibility. The event table stores one observed request with a unique ID, normalized agent identity, detection source, UTC timestamp, date, and request kind.
For each accepted request, the application sends two prepared statements in one D1 batch:
- Upsert the daily row and increment its request count.
- Insert the corresponding event row.
Cloudflare’s D1 documentation states that batched statements are SQL transactions and that a failing statement aborts or rolls back the sequence. The reviewed write path uses that boundary directly.
This avoids two misleading states: a total that increased without a detail row, and a detail row that appeared without its corresponding total. The request handler still treats telemetry as non-critical. A failed telemetry write is logged while the article response remains available. Atomicity protects the two database representations from disagreeing with each other; it does not make analytics part of the content-delivery success condition.
The stored identity is deliberately narrow. The event contains the normalized result of detection, not an IP address, raw user-agent string, or full query string. That keeps the record useful for the stated feature without turning an observability improvement into unnecessary network-identifier retention.
Preserve legacy data as a remainder
Keeping both tables creates another risk: double-counting. After the event schema is active, the daily total includes requests that also have event rows. Showing the full daily total under “historical” and then listing events would count the same request twice.
The legacy view solves this by calculating a remainder for each article, locale, agent family, and UTC date:
legacy remainder = daily aggregate − recorded event rows
Only positive remainders are returned. The public query performs that subtraction at read time. New requests appear as events; old requests that lack event dimensions remain summarized. There is no artificial cutover timestamp to maintain and no invented backfill.
This design also tolerates a mixed transition period. If an aggregate row contains ten requests and six matching event rows exist, the interface exposes six real events plus a legacy remainder of four. The result is explicit about what the database knows at each level of detail.
Use a cursor that defines a total order
An active event stream changes while readers page through it. Offset pagination can shift when a newer row arrives. Timestamp-only pagination also breaks when several events share the same timestamp.
The event query orders by two fields:
visited_atdescending.iddescending as a unique tie-breaker.
The next page requests rows older than that compound position. The cursor also includes locale, article slug, and view, so a cursor returned for one article or for the legacy view cannot silently be reused elsewhere. Page sizes are bounded, and one extra row determines whether another page exists. The event query and cursor validation make those constraints part of the API rather than client convention.
Empty, unavailable, and absent are different states
The page loads aggregate statistics, event history, and comments independently. A comment count of zero does not hide visit records. An event API failure produces a retryable error instead of an empty-success message. Legacy totals remain in their own expandable section because they carry a different evidence level.
The bilingual interface also labels detected names as unverified. A user-agent or request header can identify software, but that signal does not prove who controlled the request. The UI keeps the useful observation and its confidence boundary together.
Verification covered the transition boundaries
The tests focused on places where a clean demo can hide inconsistent data:
- Concurrent requests must preserve every aggregate increment.
- Records with the same timestamp must remain reachable across cursor pages.
- A cursor is valid only for its original article, locale, and view.
- Legacy totals must subtract matching event rows and never become negative or duplicated.
- Visit records must render when comments are empty.
- Statistics, visits, and comments must retain independent loading and failure states.
- Both language routes must present the same data contract.
The migration adds indexes for article-time pagination and daily reconciliation. The merged implementation passed repository checks and was verified on the deployed site at that exact commit.
Reusable conclusion
When an aggregate-only system gains event detail, treat the two models as different levels of evidence. Write new totals and events atomically, subtract real events from legacy aggregates, page events with a stable compound key, and label unverifiable history honestly. A richer schema should increase what the system can prove from the cutover forward without pretending that the past contained data it never recorded.