Build1 publisher3 min readPublished
Once the question needs a cube, you own the parser
A team dropped GoAccess because its panels never cross, then spent the engineering effort where it actually lands: keeping ingestion at 44.5 MB regardless of file size.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The product needs to answer which crawler fetched which URL, on which day, and what status code it got: the cross product, not 'how many hits yesterday'.
- The team started with GoAccess and abandoned it, describing that as the decision the rest of the article follows from.
- GoAccess produces a report.json containing panels: top URLs, top user agents, status code distribution, hits per day. Each panel is already aggregated and no panel is crossed with any other.
- GoAccess can report that Googlebot fetched 40,000 pages, and separately that 3,000 requests returned 404, but cannot say whether Googlebot got any of those 404s.
- The author writes: 'An aggregate you cannot cross is not data. It is a picture of data.'
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A team building crawler reporting on Laravel 13, Horizon and PostgreSQL 18 threw out GoAccess and wrote thirteen log parsers instead, because the question they sell answers to is which crawler fetched which URL, on which day, and with what status code [31][1][7]. That is not a bigger version of "how many hits yesterday" [1]; it is a different shape of answer, and the shape is what forces the build. The reason is worth reading slowly, because everything downstream follows from it. GoAccess emits a report.json of panels: top URLs, top user agents, status code distribution, hits per day, each one already aggregated and none of them crossed with any other [3]. So it will tell you that Googlebot fetched 40,000 pages, and separately that 3,000 requests returned 404, and it cannot tell you whether Googlebot got any of those 404s [4]. The author's line is the cleanest statement of the trade I have seen: "An aggregate you cannot cross is not data. It is a picture of data." [5] The requirement was a cube of date, hour, bot, URL and status code with hit counts and bytes, and once aggregation has to happen on your axes, no report-producing tool helps [6][2]. Owning the parser means owning thirteen input formats [7]. Seven are variations on the common log format and share one engine, GoAccess-style format strings compiled to a regex once and applied per line [8], which leaves six needing dedicated parsers [1]: Cloudflare JSON, Caddy JSON, Google Cloud Storage CSV, and a W3C parser that has to be stateful because IIS declares its columns in a #Fields: header partway through the file [13]. That same W3C parser handles CloudFront, whose lines are URL encoded and whose spaces arrive as plus signs [14]. Two details show where the real cost sits: %^ means "skip this field", which is how a 17-column S3 line fits in one format string [9][10], and %z is their own extension, capturing the UTC offset so timestamps normalise at parse time rather than query time [11]. Get that wrong and a daily report has 25 hours in it twice a year [12]. The benchmark, run 19 August 2026 through the real three stages of parse, classify agent, normalise URL, used a synthetic corpus deliberately hostile to memoization: 5,000 distinct paths and 240 distinct agent strings [15][16], about 1.2 million path-agent combinations [2]. The author declines to lead with throughput [32] and flags that real production logs ran nearer 52,000 lines per second because real agent strings are longer and messier [17], and that synthetic corpora flatter any parser [18]. The load-bearing number is that peak memory was 44.5 MB in that run and 44.5 MB in a previous run with a fraction of the cardinality, because nothing accumulates [19]. A 2 GB file costs the same 44.5 MB as an 86 MB one, roughly 24 times the input for identical memory [20][3], which makes the job's memory limit a constant rather than a function of what a customer uploads [21]. The write side is built for the same property. The aggregate table's unique key is the cube itself [22]; bot_token defaults to an empty string and is never null, because a null inside a unique key stops the index deduplicating [23][24]. PostgreSQL has NULLS NOT DISTINCT, SQLite, which their tests run on, does not, so the sentinel is the portable answer and every column in a uniqueness key is NOT NULL [25][26]. The upsert adds rather than replaces, which is what makes the pipeline restartable: a flush that wrote half a buffer whose keys reappear later is still correct, and two servers' logs covering the same hour merge [28][29][30]. Watch two things. The gap between synthetic and production throughput is the honest measure of the agent classifier, not the parser [17][18].