A Low-Cost Heroku Log Viewer Built on Cloudflare R2 Data Catalog

BoxHero Engineering Blog: Rebuliding Papertrail on Cloudflare R2

We run on a mix of cloud services, but the servers that actually talk to our AWS Aurora databases are hosted on Heroku. Heroku gives you live logs through heroku logs --tail, but as soon as you want to look back at yesterday's logs, or last week's, you need an external add-on. For a long time, ours was Papertrail.

The trouble started the day one of our servers crashed with a SIGILL. To pin down the cause, we had to find the exact user input that triggered the crash, and that meant logging every incoming one. (We could have written those inputs to a database or S3, but that adds latency to every request, so we wanted to keep them as lightweight stdout logs instead.)

The problem is that Papertrail bills by log volume. It felt wasteful to pay more just because one crash had ballooned our log volume. So we decided to take only the Papertrail features we used and rebuild them ourselves on top of Cloudflare.


The two features we needed

We weren't trying to clone all of Papertrail. We just needed the parts we actually used day to day, and there were only two:

  1. The filter-and-search UI. Filter by keyword, search for a specific term, then narrow down to the logs of one dyno around that time. Papertrail made this flow feel natural.
  2. Keyword alerts. Set an alert on a keyword like a 5xx status to get an email within a minute. This was how we caught server issues in near real time.

If we could reproduce just those two, we could carry our workflow over as is. Everything else (long-term retention, search performance) really just came down to choosing the right storage.


How Heroku ships its logs

So how does Heroku get logs out in the first place? Logplex keeps only a short window by default, but register an HTTP endpoint as a drain and Heroku will POST your logs straight to it. Add-ons like Papertrail receive logs the same way, through a drain of their own.

That meant we only needed one thing to start: an endpoint that accepts whatever the drain POSTs.

What the drain POSTs isn’t the tidy single line you see in heroku logs. It's wrapped in RFC 6587 octet-counting framing around RFC 5424 syslog. A single request carries multiple lines, and each line is framed as <byte-count> <syslog message>.

292 <158>1 2026-06-15T12:34:56.789012+00:00 host heroku router - at=info method=POST path="/preview" host=label2.boxhero.io request_id=1222c312-cb66-14fc-533f-c69ca55815b9 fwd="180.191.204.40" dyno=web.1 connect=0ms service=33ms status=200 bytes=19978 protocol=http2.0 tls=true tls_version=tls1.3

87 <13>1 2026-06-15T12:34:56.812345+00:00 host app web.1 - [INFO] preview rendered in 28ms

91 <40>1 2026-06-15T12:35:01.004211+00:00 host heroku web.1 - Process running mem=512M(100.0%)

The leading number (29287…) is the byte length of the syslog message that follows. The syslog header runs <PRI>VERSION TIMESTAMP HOST APP-NAME PROCID MSGID, and the fields we care about are APP-NAME = the log source (heroku/app) and PROCID = the dyno (routerweb.1). Heroku follows RFC 5424 loosely: it ships a bare MSGID ('-') with no STRUCTURED-DATA field, and everything after that is the message body. So in the Worker, we slice frames apart by the length prefix, parse the fixed fields only up through MSGID, and treat the rest as the message verbatim.


Architecture

The whole thing is built entirely from Cloudflare products. Data flows in one direction (ingest → store → search), with ”alerting” branching off the ingest path.

Heroku app
   │  HTTPS log drain
   ▼
Worker  POST /ingest/:uuid
   │  (uuid→app D1 lookup, logplex parsing)
   │
   ├── keyword match ──▶ D1 pending ──▶ per-minute cron
   │                                        │
   │                                        ▼
   │                                 Postmark email alert
   ▼
Pipeline (batch flush)
   │
   ▼
R2 Data Catalog (Iceberg) ◀── R2 SQL ◀── React SPA (web UI)

The products in play:

  • Workers: a single edge Worker that handles drain ingestion, the web UI, search, and the alert cron in one deployment.
  • D1: the registry of apps (= ingest endpoints) and the alert store. It holds uuid→app-name mappings, keyword alert rules, and the pending-notification queue with strong consistency.
  • Pipelines: batches incoming log records and flushes them to the R2 sink, avoiding a per-record R2 write.
  • R2 Data Catalog (Apache Iceberg): the log store. Cheap object storage plus a columnar table format, well suited to long-term retention.
  • R2 SQL: queries the Iceberg tables with SQL, so we can filter and search logs without standing up a separate database or indexing infrastructure.

Ingest: receiving the drain in a Worker

Register the Worker's …/ingest/<uuid> address as a Heroku drain, and the Worker parses the logplex format into records. Since this is a multi-app tool, authentication splits two ways.

  • Ingest (/ingest/:uuid): attaching an auth header to a Heroku drain is awkward, so we treat the unguessable uuid in the URL itself as the credential. We only accept requests when a matching app exists in D1.
  • Everything else (web UI, search, app/alert management): protected by Basic auth.

Store: Pipeline → R2 Data Catalog

We don't write incoming records straight to R2; we hand them to a Pipeline. Writing into Iceberg means buffering up to some time or size threshold and appending in one shot, and that intermediate buffer is tedious to build yourself. With a Pipeline, you delegate the batching and connect it directly to the R2 Data Catalog.

We wanted logs to show up as fast as possible, so we set it to flush every minute. That's also the design's one real limitation: because logs load in batches, real-time tail like Papertrail's isn’t possible. There's roughly a one-minute delay before a log appears on screen. Our goal was to store and search yesterday's and last week's logs cheaply (for a long time), so we accepted the trade-off.

The R2 Data Catalog layers an Iceberg API over R2. Iceberg supports ACID transactions and stores high-volume data like logs in columnar form, so a later query pulls only the columns it needs. We partition by ingest time at day granularity. Logs are stored the instant they arrive, so ingest time nearly matches event time: query a given time range and you scan only that day's partition.

Turn on auto maintenance and it runs compaction and snapshot expiry every hour to keep query performance up. One caveat: the data retention (purge) that actually deletes old logs is not part of auto maintenance. More on that below.

Search: R2 SQL

The accumulated Iceberg data is queried with R2 SQL. We could have stood up a query engine like DuckDB ourselves, but R2 SQL worked out of the box with no infrastructure to manage. The UI itself is written in React and calls R2 SQL to search.

Search isn't a single naive LIKE; we put a bit more work into it.

  • The query is tokenized on whitespace, and each token becomes an ILIKE AND condition. Search for error timeout and you get only lines containing both words.
  • Characters like %_, and \ in user input are escaped to literals so they don't misfire as wildcards, which also blocks SQL injection.
  • The dyno (proc) filter is a prefix match, so web matches both web.1 and web.2.
  • Every query is capped to a 30-day window to keep a rare search term from scanning the whole table (more on this in “Retention” below).

Alerting: keyword match → email

Alerts were the second requirement. "If a 5xx shows up, email me within a minute" was a core workflow, so a viewer alone wasn't enough.

  1. You register keyword alert rules per app. Matching is case-insensitive substring.
    1. include is split on whitespace and matched as AND (a line must contain all of them).
    2. exclude lists keywords that suppress a match.
    3. Recipients can be a comma-separated list.
  2. At ingestion time, each line is matched against the alert rules. On a match, it's pushed onto D1's pending_notifications queue. This matching and buffering is best-effort, so a failure never affects ingestion (the 200 response still goes out).
  3. A cron runs every minute, groups the pending rows by alert, and sends one batched email each via Postmark. Processed rows are deleted whether or not the send succeeded (no retries).

To prevent alert storms, each alert has a per-minute cap (50 by default). Even if thousands of lines match at once, a single email carries up to the cap and marks the rest as truncated.


Running it day to day

We landed on a UI very close to Papertrail's, and we can manage all the Heroku apps we operate from one place. Each app gets an ingest endpoint to wire into its Heroku drain, and we read logs with infinite scroll just like Papertrail. Filter, then expand the full logs around that time window, or narrow to a single dyno's logs. It all works the same way.

Retention

This tool is effectively a "last-30-days log viewer."

Every query carries a forced ts >= now - 30 days floor. The data further back is all there, but in practice we almost always look within the last 30 days, so we cap the window to keep queries fast and cheap. The floor doubles as a safety valve: filter on a rare search term and an uncapped query could fail to fill its LIMIT and scan backward across the entire table, which would drive up R2 SQL costs.

But while queries stop at 30 days, actually deleting old data is a separate problem. Auto maintenance does more than it used to: snapshot expiry now reclaims the underlying data files too, not just the Iceberg metadata, so files that fall out of the active snapshots get cleaned up on their own. What it still won't do is enforce a retention window — nothing drops your log rows just because they've aged past 30 days. To actually purge old records you have to issue the DELETE yourself as an Iceberg query, which then lets the next snapshot expiry reclaim those files.

Fortunately, R2 storage is so cheap that letting data pile up barely costs anything. So we don't bother cleaning up often. A once-a-year pass seems plenty. 😁

Cost

Cost was the whole reason we built this, so we did the math on how much we'd actually save. For comparison, assume we keep roughly 500MB/day (about 15GB/month) searchable for 30 days. Log everything, including user input, and you hit that pretty quickly.

  • Papertrail prices its plans by log volume plus searchable retention. The closest plan to the spec above (550MB/day, 30-day search) is Norden, at $69/month. If logs grow to 2GB/day, you jump straight to Liatorp at $125/month. In other words, the very act of logging more to chase a crash lands directly on your bill.
  • Cloudflare keeps most of the same workload inside the free tier (rates from the official R2 and Pipelines pricing, as of June 2026).

Item Rate Cost at 15GB/month
Workers Paid (cron, Worker execution) $5/month flat $5 ($0 if already subscribed)
Pipelines — stream ingress Free $0
Pipelines — sink (Iceberg) 50GB/month free, then $0.06/GB $0 (15GB < 50GB)
R2 storage $0.015/GB·month (10GB free) Under $1/month after compression
R2 SQL / Data Catalog queries Free during beta (storage and ops billed) $0
Total ~$5/month

So we went from $69/month to about $5, and if you're already on Workers Paid, it's effectively a few cents of storage.

The more important difference is scalability. Papertrail jumps to the next plan as logs grow, but in our setup the volume can multiply several times over and still sit comfortably inside the free tier. We can log as much as the problem demands without worrying about the bill.

It compounds across apps, too. We operate several Heroku apps, and they all feed into a single deployment of this tool (one Worker, one pipeline, one bucket), so that $5 base is shared across all of them, and each app we onboard adds essentially nothing. With Papertrail it's the opposite: each app carries its own plan, so the bill multiplies with every app. N apps is roughly N times the monthly cost. The more apps you consolidate, the wider the gap gets.

What we learned

Our biggest misstep was turning on auto maintenance too late. We didn't enable it from the start, and by the time we did, after snapshots had piled up, the compaction it had to do in one pass timed out. Our best guess is that the data was split across thousands of metadata files, so cleaning it all up took too long.

The root cause traces back to the one-minute flush. A short flush interval means logs show up fast, but it also means the number of small files grows quickly. Once thousands of them accumulate, query planning gets expensive, and the compaction you meant to do in one pass fails on timeout.

⚠️
If you build this, turn on auto maintenance from day one, before data builds up. Retrofitting it after thousands of small files have accumulated is the one mistake that actually cost us time.

We ended up attaching to the catalog locally with PySpark and cleaning up the metadata once by hand. After that, the hourly maintenance ran cleanly every time.

One more thing we tuned along the way: snapshots themselves. The more of them pile up, the slower query planning seems to get, and since we don't need Iceberg's time travel at all, there's no reason to hold onto old ones. So we set auto maintenance to keep a minimum of 5 snapshots and expire anything older than 1 hour. That keeps the table lean without us having to think about it.


Wrapping up

It started with a single SIGILL crash we resented paying to investigate. And in the end, this tool is what caught it. Once everything was logged and searchable, we found that one specific user's input was triggering the crash deep in Skia's native layer. With the offending input identified, guarding against that case was straightforward.

Looking back, the project succeeded on two fronts: reproducing Papertrail's workflow (search UI + keyword alerts) almost verbatim on top of Cloudflare, and running it at a cost that stays flat no matter how much we log.

We no longer ration logs to control the bill. We can log freely, whenever we need to, and trace the problem.

Sure, there are trade-offs. Live tail is gone, purging old data is manual, and like anything self-built, it needs occasional upkeep. But what we needed was cheap, long-lived, searchable logs, and that's what we have now.