Event Explorer
I've always liked observability tooling โ there's something satisfying about being able to throw an arbitrary query at a mountain of data and get an answer back in milliseconds. Event Explorer is my attempt at building the piece of that experience I find most interesting: a Lucene-style search box over a table of events that have no fixed schema at all, and making sure it stays fast even as that table grows past a million rows on nothing but my laptop.
What it does
Under the hood, every event is just a free-form JSON body โ a purchase, a login, a shipment, whatever. There's no owner_id column, no type column with a fixed enum. And yet the UI lets you type things like:
- owner_id:usr_00042 โ works for any key, known or not
- user.is_active:true โ nested JSON, dot-notation
- type:book AND owner_id:usr_00042 โ full boolean logic, parentheses
- type:err* OR type:fail* โ wildcards
- amount:>100 โ numeric comparisons against a JSON field
- created_at:[2024-01-01 TO 2024-01-02] โ ranges
- usr_00042, no field at all โ haystack search: find this value under any key, regardless of what that key is called
That last one is the scenario I actually designed the seed data around: a book event's owner_id and a lunch event's eater_id can hold the same person under two completely different key names. Typing a bare id turns up a handful of events scattered across several event types, out of a million rows โ and it still comes back in milliseconds.
Stack
- ClickHouse for storage โ one events table, MergeTree, indexed for fast filtering on both known and unknown JSON keys
- Go + Huma v2 for the API, on a chi router โ request validation, error handling, and a generated OpenAPI spec for free
- clickhouse-go/v2, the native-protocol client, for both querying and batch seeding
- go-lucene, which parses the query string into an AST; I wrote a custom ClickHouse dialect on top of it that renders that AST into real SQL โ this was the trickiest and most fun part of the whole project
- go:embed for the frontend โ the whole UI compiles straight into the server binary, no separate static deploy step
- A vanilla HTML/CSS/JS frontend, no build tooling, no framework
Why this was hard: making an unknown key fast to filter on
The core problem is that you can't pre-declare a column, or a normal index, for a JSON field you don't know exists yet. Everything interesting in this project is really just different answers to that one problem, stacked on top of each other.
The payload gets stored two ways: body keeps the original JSON, byte for byte, and attributes is that same JSON flattened into a Map(LowCardinality(String), String) โ nested objects become dot-joined keys, every scalar leaf becomes one map entry. owner_id:usr_00042 and user.is_active:true actually query against that map, not against body and not through a JSON-parsing function.
On top of that sit three different kinds of ClickHouse data-skipping indexes, because the problems they solve are genuinely different: a set(100) index on the truly low-cardinality columns (event_type, source) that can guarantee a block doesn't match; Bloom filters on mapKeys()/mapValues() of the attributes map, since attribute keys and values are arbitrarily high-cardinality and Bloom filters degrade gracefully instead of failing outright; and a tokenizing Bloom filter (tokenbf_v1) on the raw body, which is what makes the "search any key" free-text query possible at all โ there's no way to build a per-key index for a key you don't know about in advance, so instead I tokenize the raw bytes and treat every token as a candidate needle.
I didn't want to just assume all of this actually helped, so I checked it with EXPLAIN indexes = 1 against the live, seeded table: on a selective value, the token-based search pruned to 35 of 1235 granules, a measured ~6.5x wall-clock win. I also tried the tempting simplification of swapping that whole tokenize-and-rewrite approach for a plain body LIKE '%value%' โ and rejected it, because LIKE with wildcards on both sides read all 1235 granules every single time on this ClickHouse version. Worth actually running the query instead of trusting the intuition that "it's basically the same index."
Architecture
web/static/*.html,css,js --go:embed--> web/embed.go
cmd/server/main.go -> chi router -> Huma v2 handlers (internal/api)
-> internal/chstore.Store: Search / GetByID / Stats
-> internal/chquery.Translate(query)
(go-lucene AST -> ClickHouse SQL)
-> ClickHouse
cmd/seed/main.go -> internal/seedgen (synthetic events) -> internal/flatten (JSON -> Map)
-> chstore.Store.PrepareEventBatch (native batch insert)Running it
Docker Compose does most of the work:
make up # docker compose up --build -d (clickhouse + api)
make seed # seed 1,000,000 synthetic events, well under a minuteThen it's just localhost:8080, with interactive API docs at /docs.
Conclusion
Event Explorer is a small demo on the surface โ a search box and a results table โ but almost all of the actual work went into the six or seven layers underneath it that make an unknown JSON key just as fast to filter on as a real column would be. That's the part of backend work I enjoy most: not the CRUD, but figuring out exactly which mechanism is doing the work, and then actually measuring it instead of assuming.
As always, thank you for reading. I really appreciate it. ๐