<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Florian Marending</title><description>Technical musings</description><link>https://marending.dev/</link><item><title>Self hosting maps</title><link>https://marending.dev/notes/pmtiles/</link><guid isPermaLink="true">https://marending.dev/notes/pmtiles/</guid><description>PMTiles are delightfully boring</description><pubDate>Sat, 22 Aug 2026 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;23 Aug 2026&lt;/p&gt;&lt;h1&gt;Self hosting maps&lt;/h1&gt;&lt;h2&gt;PMTiles are delightfully boring&lt;/h2&gt;&lt;p&gt;For an upcoming &lt;a href=&quot;/notes/wide-events/&quot;&gt;project&lt;/a&gt;, I’m going to need a world map in a web app to
display location data. I’ve done that before, using &lt;a href=&quot;https://www.mapbox.com/&quot;&gt;mapbox&lt;/a&gt; as the tile
server, but this time I want to serve the map data myself. A couple of years ago I stumbled across
&lt;a href=&quot;https://github.com/protomaps/pmtiles&quot;&gt;PMTiles&lt;/a&gt;, at the time a rather novel way to serve maps. I
didn’t have a need for it then, so it just sat in the back of my mind as something to take a closer
look at later. Now the time has come, so in this note I’m going to play around with PMTiles and see
if they fit my needs.&lt;/p&gt;
&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;
&lt;p&gt;First, a short explainer: PMTiles is a file format crafted so that individual mapping tiles can be
retrieved using HTTP Range requests. You can chuck such a file onto S3, or serve it from an HTTP
server like Caddy, and a suitable JS library can immediately start retrieving the tiles it needs for
the current map location and zoom level, all from that one file.&lt;/p&gt;
&lt;p&gt;Compare that to a tiling server, which has map data on disk but needs a runtime component to serve
it in the appropriate format. The advantage is clear. Mind you, that comes with some disadvantages.
For example, PMTiles are read-only, so you can’t update part of a map in place. Protomaps has a good
&lt;a href=&quot;https://protomaps.com/blog/you-might-not-want-pmtiles/&quot;&gt;article about the tradeoffs&lt;/a&gt; if you want to
read more.&lt;/p&gt;
&lt;h2 id=&quot;boring-technology&quot;&gt;Boring technology&lt;/h2&gt;
&lt;p&gt;For my usage, I’ll prepare a map file to my liking and serve it from my server with Caddy. If I want
to include the latest OpenStreetMap changes, I just recreate the map and that’s it. It’s delightful.
No additional moving parts to manage and operate. More technologies should be like this.&lt;/p&gt;
&lt;h2 id=&quot;creating-pmtiles&quot;&gt;Creating PMTiles&lt;/h2&gt;
&lt;p&gt;With the musings out of the way, let’s get practical. First, grab the latest &lt;em&gt;build&lt;/em&gt; from
&lt;a href=&quot;https://maps.protomaps.com/builds/&quot;&gt;this page on Protomaps&lt;/a&gt;. It’s essentially a dump of all
OpenStreetMap data for the whole planet. We don’t want to download it, just note the filename.&lt;/p&gt;
&lt;p&gt;Next, using the &lt;a href=&quot;https://github.com/protomaps/go-pmtiles&quot;&gt;PMTiles CLI&lt;/a&gt;, we can generate our own
PMTiles file of the world, capped at a maximum zoom level. That limits how much detail you see when
zooming in close, and in turn keeps the file much smaller.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;./pmtiles extract https://build.protomaps.com/20260823.pmtiles world.pmtiles --maxzoom=4&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives us a 6 MB file. Super small, but also not so detailed.&lt;/p&gt;
&lt;p&gt;Next, let’s create a high-fidelity file for a single region. For that, we need a bounding box around
the region we care about. To avoid juggling coordinates by hand,
&lt;a href=&quot;https://bboxfinder.com/&quot;&gt;bbox finder&lt;/a&gt; lets you draw a box easily.&lt;/p&gt;
&lt;p&gt;Then we can generate a full-fidelity extract of that region like so:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;./pmtiles extract https://build.protomaps.com/20260823.pmtiles zurich.pmtiles --bbox=8.497925,47.339055,8.588905,47.395444&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This results in a 6.3 MB file. Viewing it on &lt;a href=&quot;https://pmtiles.io/&quot;&gt;pmtiles.io&lt;/a&gt; shows every detail in
Zurich, but nothing outside the bounding box.&lt;/p&gt;
&lt;p&gt;What’s really nice is that we can now combine the two files into one with the advantages of both.
That only works if the files don’t overlap, though. So far, we have the whole world up to zoom level
4, but Zurich at every zoom level. To make it work, we need Zurich only from zoom level 5 and up:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;./pmtiles extract zurich.pmtiles zurich-hi.pmtiles --minzoom=5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Resulting in a slightly smaller file at 5.8 MB. Now, the two files are non-overlapping and can be
combined like so:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;./pmtiles merge world.pmtiles zurich-hi.pmtiles combined.pmtiles&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This results in a 12 MB file with high detail around Zurich and a low-fidelity basemap for the rest.
Neat!&lt;/p&gt;
&lt;p&gt;There’s one catch, depending on the renderer: in the plain &lt;code&gt;world.pmtiles&lt;/code&gt; basemap, I can zoom past
&lt;code&gt;maxzoom&lt;/code&gt; and still see a low-fidelity map. With the combined map, however, zooming past &lt;code&gt;maxzoom&lt;/code&gt;
turns everything outside Zurich black. That’s because the combined file declares &lt;code&gt;maxzoom: 15&lt;/code&gt; in
its header: the renderer expects zoom level 15 everywhere and paints black wherever that data is
missing.&lt;/p&gt;
&lt;p&gt;To fix this, configure the JS rendering lib with two sources at different zoom levels, both pointing
to the same file.&lt;/p&gt;
&lt;h2 id=&quot;verdict&quot;&gt;Verdict&lt;/h2&gt;
&lt;p&gt;It’s truly as simple and easy as it says on the box. Consider me impressed. For my purposes, I might
explore automatically deriving a set of high-fidelity bounding boxes from my location data and
merging them into one ultimate file. But that may well be overengineering.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Apple M1 vs Hetzner CAX21</title><link>https://marending.dev/notes/asahi-benchmarks/</link><guid isPermaLink="true">https://marending.dev/notes/asahi-benchmarks/</guid><description>Benchmarking homeserver hardware</description><pubDate>Fri, 07 Aug 2026 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;08 Aug 2026&lt;/p&gt;&lt;h1&gt;Apple M1 vs Hetzner CAX21&lt;/h1&gt;&lt;h2&gt;Benchmarking homeserver hardware&lt;/h2&gt;&lt;p&gt;I’m in the process of migrating my VPS to a &lt;a href=&quot;/notes/asahi-setup/&quot;&gt;MacBook Air M1 running Asahi&lt;/a&gt; in
my closet. Before I decommission the VPS though, I’m curious to see what the performance difference
is. So here are a couple of non-representative benchmarks comparing the M1 to an Ampere-based CAX21
at Hetzner.&lt;/p&gt;
&lt;p&gt;By the way, I’ve benchmarked these Arm-based Hetzner machines before.
&lt;a href=&quot;/notes/vps-benchmarks/&quot;&gt;Last time&lt;/a&gt;, I was in the process of migrating from an x86 machine to the
CAX31. In the meantime, I’ve downgraded to the CAX21, which is why these numbers don’t exactly match
up.&lt;/p&gt;
&lt;p&gt;Anyway, here are the specs of the two machines:&lt;/p&gt;
&lt;p&gt;CAX21: &lt;strong&gt;4 vCPU, 8 GB RAM&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;M1: &lt;strong&gt;4 performance cores, 4 efficiency cores, 16 GB RAM&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Note that we’re comparing a VPS with noisy neighbors and a modest production load to an idle M1.
Also, the tests are rather short, so the impact of thermal throttling on the passively cooled M1 is
not visible. I did say these would be non-representative benchmarks.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;sysbench cpu --threads=x run&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 1. sysbench CPU benchmark results&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;sysbench memory --threads=x run&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 2. sysbench memory benchmark results&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;p&gt;I have no clue what happened here at 8 threads for the M1. I guess the performance cores don’t like
to share.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;sysbench fileio --threads=x --file-test-mode=seqwr run&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 3. sysbench fileio benchmark results (--file-test-mode=seqwr)&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;p&gt;And finally, I wanted to see how they compare when compiling one of my Rust code bases.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;time cargo build --release&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 4. Compilation time of Rust application&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;h2 id=&quot;verdict&quot;&gt;Verdict&lt;/h2&gt;
&lt;p&gt;It’s simultaneously unsurprising that a dedicated M1 outperforms a tiny slice of a server CPU, and
yet I’m always blown away by what this little 6-year-old machine can do. Needless to say, this
migration will be a nice little upgrade, although I’m more excited about the energy efficiency of
the Air (&amp;lt;2W idle) than its raw power.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Asahi Linux on a Macbook Air M1</title><link>https://marending.dev/notes/asahi-setup/</link><guid isPermaLink="true">https://marending.dev/notes/asahi-setup/</guid><description>Setting up a headless server</description><pubDate>Mon, 22 Jun 2026 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;23 Jun 2026&lt;/p&gt;&lt;h1&gt;Asahi Linux on a Macbook Air M1&lt;/h1&gt;&lt;h2&gt;Setting up a headless server&lt;/h2&gt;&lt;p&gt;I’ve been wanting to move my server from a VPS hosted at Hetzner to my own hardware for a while.
After scouring the internet for suitable low-power devices, think Raspberry Pi but more powerful, I
landed on a weird idea: What if I just repurpose my Macbook Air M1 as a server? It’s still a super
performant machine and consumes barely any power idling. After some research it seems like this
should be well possible thanks to the Asahi Linux project. So here we go.&lt;/p&gt;
&lt;p&gt;I’ll use this chance to revamp my &lt;a href=&quot;/notes/server-setup/&quot;&gt;server setup&lt;/a&gt; &lt;em&gt;again&lt;/em&gt;, but that’s not the
focus of this note. Here I just want to run through the fundamental software tweaks necessary to
make this device capable of acting as a server. All the other basic hardening that should be applied
to any server I leave out as that is better found in a serious server setup guide.&lt;/p&gt;
&lt;br/&gt;
&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt;: Install Asahi from macOS as described in &lt;a href=&quot;https://asahilinux.org/&quot;&gt;their documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2&lt;/strong&gt;: Ensure device doesn’t sleep when lid is closed.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;# /etc/systemd/logind.conf.d/lid-ignore.conf
[Login]
HandleLidSwitch=ignore
HandleLidSwitchDocked=ignore
HandleLidSwitchExternalPower=ignore&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3&lt;/strong&gt;: Turn screen off when lid is closed. Don’t want to waste power.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sudo grubby --update-kernel=ALL --args=&amp;quot;consoleblank=30&amp;quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 4&lt;/strong&gt;: Set battery charge limit to 80%. Let’s keep the battery healthy and avoid it exploding
in my closet.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;echo &amp;#39;SUBSYSTEM==&amp;quot;power_supply&amp;quot;, KERNEL==&amp;quot;macsmc-battery&amp;quot;, ATTR{charge_control_end_threshold}=&amp;quot;80&amp;quot;&amp;#39; | sudo tee /etc/udev/rules.d/10-battery.rules&lt;/code&gt;&lt;/p&gt;
&lt;h2 id=&quot;voilà&quot;&gt;Voilà&lt;/h2&gt;
&lt;p&gt;With those basic tweaks I can plug my Macbook into power and Ethernet and set it aside. It acts as a
neat little arm64 server while sipping around 1.75 Watts at idle. &lt;em&gt;Great success&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&quot;known-issues&quot;&gt;Known issues&lt;/h2&gt;
&lt;p&gt;One inconvenience I found is that my USB-C to Ethernet adapter doesn’t get listed when the server
reboots. I have to physically plug it in again for it to be picked up. None of the common
workarounds work for me. I &lt;em&gt;have&lt;/em&gt; tried a different dongle, which seems to work. That one is however
slower and seems to consume about 3 Watts just by itself. It also gets pretty warm just sitting
there. So I’ll just live with having to be home when I want to reboot the machine after updates.
That’ll do for my purposes. If it turns out to be annoying I may go hunting for another adapter.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Towards approachable observability with wide events</title><link>https://marending.dev/notes/wide-events/</link><guid isPermaLink="true">https://marending.dev/notes/wide-events/</guid><description>A tragedy in four acts</description><pubDate>Sat, 16 May 2026 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;17 May 2026&lt;/p&gt;&lt;h1&gt;Towards approachable observability with wide events&lt;/h1&gt;&lt;h2&gt;A tragedy in four acts&lt;/h2&gt;&lt;p&gt;I like to think about how to get operational insights into the services I host. It’s become kind of
a hobby, to be honest. Maybe this whole thing is just bike shedding. What better way to avoid the
crushing reality that an actual project you had in mind doesn’t live up to your expectations than by
not even attempting it, and instead building infrastructure around your existing applications? Maybe
that’s a topic for therapy.&lt;/p&gt;
&lt;p&gt;Anyhow, I realized during my latest manic observability episode that I barely even remember how I
got here. So for posterity, I’m outlining the history and motivations for all the different phases
in this note.&lt;/p&gt;
&lt;h2 id=&quot;act-1-metrics-and-prometheus&quot;&gt;Act 1: Metrics and Prometheus&lt;/h2&gt;
&lt;p&gt;A couple of years ago I got the itch to play around with embedded devices. I got myself an ESP32 and
a CO2 sensor and got to work. With a little elbow grease, I had the current CO2 concentration
displayed on a small seven segment display. Soon, I graduated to wanting to store and chart this
data.&lt;/p&gt;
&lt;p&gt;At this point I had little knowledge on how to approach this, so I went the well-trodden path:
Prometheus to store time series data and Grafana to visualize it. Since my microcontroller was not
internet accessible, I had to put a &lt;a href=&quot;https://github.com/prometheus/pushgateway&quot;&gt;push gateway&lt;/a&gt; in
front of Prometheus to allow pushing data into the system, instead of having Prometheus scrape the
metrics off the device on a regular schedule.&lt;/p&gt;
&lt;p&gt;Around this time I also built and deployed this website. I had seen a cool statistics section on
another blog, where one could view how many page views the blog was getting and which pages were
popular. Wanting the same for this site, I initially transformed the static site into one with a
NodeJS backend just for this feature. After a short while I realized I wasn’t happy with that and
ripped it out again, planning instead to track visitors with an external service. You can watch this
unfold in &lt;a href=&quot;/notes/performance/&quot;&gt;this old note&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I could have used my existing Prometheus setup to scrape Caddy for access metrics, but that wouldn’t
have given me all the data I wanted. I’d need
&lt;a href=&quot;/notes/rust-spa/&quot;&gt;a dedicated service serving the site&lt;/a&gt; and exposing metrics. So this kicked off
the realization that I needed a broader observability system than what Prometheus could give me.
What was always clear to me, by the way, is that I wanted one unified system for metrics and service
monitoring.&lt;/p&gt;
&lt;p&gt;In hindsight, I also realize there was another push factor: PromQL. I got annoyed with learning a
language with such a narrow use case.&lt;/p&gt;
&lt;h2 id=&quot;act-2-metrics-with-duckdb&quot;&gt;Act 2: Metrics with DuckDB&lt;/h2&gt;
&lt;p&gt;With all this in place, the time was ripe to venture out. And boy did I. The requirements for my
next system were clear: it should accept arbitrary JSON payloads (like sensor readings or access
logs), store them efficiently, and allow querying them in an ergonomic fashion.&lt;/p&gt;
&lt;p&gt;Notice the shift away from time series data to something more general, something that could cover
multiple use cases. You can follow the journey from
&lt;a href=&quot;/notes/unstructured-data/&quot;&gt;musing on how to store that data&lt;/a&gt; to
&lt;a href=&quot;/notes/sqlite-vs-duckdb/&quot;&gt;benchmarking suitable databases&lt;/a&gt; in my notes.&lt;/p&gt;
&lt;p&gt;Coming away from PromQL, I was craving more expressive power for querying and transforming data. So
I started looking into building &lt;a href=&quot;/notes/wasm-benchmark/&quot;&gt;a WebAssembly plugin system&lt;/a&gt; to safely
execute efficient transformations server-side. For visualization, I wasn’t satisfied with Grafana
anymore either — I wanted to go custom there as well. So I built a SolidJS frontend with
&lt;a href=&quot;/notes/responsive-plots/&quot;&gt;responsive plots&lt;/a&gt; using Observable Plot. All this started taking shape
under the name &lt;a href=&quot;https://github.com/beingflo/observatory&quot;&gt;observatory&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/charts.DHb2_JzR_15vb1o.webp&quot; alt=&quot;charts&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2879&quot; height=&quot;1414&quot;&gt;&lt;/p&gt;
&lt;p&gt;You might already be guessing that this would not go so well. Indeed, the sensor readings and GPS
location data from my phone were handled fine, but I was missing a puzzle piece on how access logs
fit in. How would I instrument my services in such a way as to get the data into observatory in a
queryable form? I found the answer in the form of
&lt;a href=&quot;https://opentelemetry.io/docs/concepts/signals/traces/&quot;&gt;traces&lt;/a&gt;. At first I wanted to build a
tracing collector into observatory, but that proved to be difficult. Instead, I turned towards a
more off-the-shelf stack to ingest tracing data.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/location.dzV7fXyK_q9tPu.webp&quot; alt=&quot;location&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2859&quot; height=&quot;1397&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;act-3-tracing-with-clickhouse&quot;&gt;Act 3: Tracing with ClickHouse&lt;/h2&gt;
&lt;p&gt;When I learned about tracing — not just in the context of distributed systems, but the general
concept of spans and events — I felt like I had hit the jackpot. Events you emit within spans mimic
log lines, except they are naturally associated with a particular request. No correlation id needed
(it’s the span id, and the tooling handles it for you). Metrics are just hardcoded aggregations done
in the application, which lends itself to very space-efficient storage in exchange for limited
flexibility. With traces, you can generate any metrics you might care about after the fact. Tracing
is kind of a one-stop-shop for observability.&lt;/p&gt;
&lt;p&gt;So I set up an OpenTelemetry collector that would receive traces and store them in a
&lt;a href=&quot;/notes/clickhouse/&quot;&gt;ClickHouse&lt;/a&gt; database. That part made a lot of sense, and instrumenting my
services with the excellent &lt;code&gt;tracing&lt;/code&gt; crate is a walk in the park. Getting that sent off
&lt;a href=&quot;/notes/otel/&quot;&gt;not so much&lt;/a&gt;, though. But now the sensor and GPS data didn’t fit the bill so cleanly
anymore. I didn’t want to have to send that data in a format the collector would understand, so I
built a small service I called &lt;a href=&quot;https://github.com/beingflo/events&quot;&gt;events&lt;/a&gt; that accepts the data
and simply declares a span with the received attributes. This way, the tracing machinery in &lt;em&gt;that&lt;/em&gt;
service takes care of getting the data into ClickHouse (via the collector).&lt;/p&gt;
&lt;p&gt;So with this, I finally realized the dream of having one central system where all my data flows
together:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/grafana.BbMJEzdy_c6tK9.webp&quot; alt=&quot;grafana screenshot&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2253&quot; height=&quot;1691&quot;&gt; &lt;img src=&quot;/_astro/access-logs.BAE78-XK_16weT4.webp&quot; alt=&quot;grafana screenshot&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2255&quot; height=&quot;1829&quot;&gt;
&lt;img src=&quot;/_astro/location-grafana.CdpJj6vt_Z1oPK4I.webp&quot; alt=&quot;grafana screenshot&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2248&quot; height=&quot;1078&quot;&gt;&lt;/p&gt;
&lt;p&gt;So this is great. Just a couple of problems:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I don’t like OpenTelemetry because I don’t understand it.&lt;/strong&gt; I would prefer simpler protocols,
something I could build myself in a pinch. The docs are a mess, too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I don’t like operating ClickHouse and Grafana on my server.&lt;/strong&gt; They’re great at what they do, but I
have a need for simpler software. I suffer from not-invented-here syndrome, ok?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I don’t like the way the data is laid out in the DB.&lt;/strong&gt; I can put any data into the DB just by
annotating a function with a span, no problem. But it’s very inefficient at storing, say, an 8 byte
sensor value. The fact that each span is its own row also makes aggregating data across all spans
belonging to one request quite tedious.&lt;/p&gt;
&lt;p&gt;I’m craving simplicity.&lt;/p&gt;
&lt;h2 id=&quot;act-4-wide-events-with-parquet&quot;&gt;Act 4: Wide events with Parquet&lt;/h2&gt;
&lt;p&gt;For more background on this, please read the
&lt;a href=&quot;/notes/duckdb-parquet/&quot;&gt;preceding note on Parquet files&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Apparently other people have had the same thoughts. Some call it Observability 2.0, others wide
events. I’ll give you a brief rundown; for more details and a good list of further reading, consult
this &lt;a href=&quot;https://jeremymorrell.dev/blog/a-practitioners-guide-to-wide-events/&quot;&gt;blog post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The idea is simple: for a “unit of useful work”, whatever that means for your system, collect an
object with relevant properties and send it off to your observability system upon completion of the
work. For a web backend, the unit of work you care about most is almost certainly the handling of a
request. But it may also be the execution of a scheduled task. So for the request, you would track
url, method, response status, headers, and all the usual suspects. But if you go all the way, you
may even add current memory utilisation and other environmental factors.&lt;/p&gt;
&lt;p&gt;Fundamentally, this is not that different from traces applied to a monolith. The data is just more
easily queryable. And what I particularly appreciate: no need for sophisticated machinery.&lt;/p&gt;
&lt;p&gt;The plan is, once again, simple: build a system that accepts arbitrary JSON payloads. Regularly
write the buffered payloads into Parquet files. That’s it. That’s the write path. To visualize the
data, I’ll build custom dashboards just like in Act 2. The storage engine enabling fast queries will
be DuckDB. Instrumentation on the services should be rather straight-forward as well. All I need to
do is keep track of some context throughout the handling of a request to write properties into.&lt;/p&gt;
&lt;p&gt;What I’m hoping to achieve with this approach: a simpler wire protocol that any service and device
can directly talk - just JSON. A simpler data model, plain files for easier backup. A simpler
operating model, no queries, no load. More powerful and elegant visualizations. The simplicity I’ll
gain of course comes at the cost of significant effort to build this system. But I’m more than happy
to pay for it.&lt;/p&gt;
&lt;p&gt;I’m writing this as I gear up to make Act 4 a reality, sorry to leave you hanging like this. Once it
is done I’ll add a more satisfying resolution here. Or maybe Act 5. We’ll see.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Exploring the Variant type in Parquet</title><link>https://marending.dev/notes/duckdb-parquet/</link><guid isPermaLink="true">https://marending.dev/notes/duckdb-parquet/</guid><description>Let&apos;s get shredding</description><pubDate>Sun, 10 May 2026 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;11 May 2026&lt;/p&gt;&lt;h1&gt;Exploring the Variant type in Parquet&lt;/h1&gt;&lt;h2&gt;Let&apos;s get shredding&lt;/h2&gt;&lt;p&gt;Observability has been on my mind again lately. I already touched on how to
&lt;a href=&quot;/notes/unstructured-data/&quot;&gt;store unstructured data&lt;/a&gt; a while back. After a brief stint building my
own system on top of DuckDB, I’ve been running an off-the-shelf combination of ClickHouse and
Grafana to collect and visualize tracing data. It has served me well, but a planned move of my side
projects to my own hardware has me re-evaluating the setup. ClickHouse is by far the heaviest
service I run on my server, and it has a habit of filling up my disk with logging tables. Look,
ClickHouse is an engineering marvel, but it’s not &lt;em&gt;my&lt;/em&gt; marvel. It’s someone else’s marvel. And I
have a need to create my own. Thing, not marvel, I mean. So here we are.&lt;/p&gt;
&lt;p&gt;Now, I’ve stumbled across the Parquet file format, and in particular its new data type that looks
like it was custom-made to solve all my problems. Maybe not all of them. In this note I want to
explore how DuckDB can write and query these files. If this works out, I may be tempted to build a
new observability system around
&lt;a href=&quot;https://isburmistrov.substack.com/p/all-you-need-is-wide-events-not-metrics&quot;&gt;wide events&lt;/a&gt; on top of
these technologies.&lt;/p&gt;
&lt;p&gt;But before we get into the weeds, let me set the scene. Let’s say I have a service that sends
structured logs (or wide events) to my observability system. Today they might look like this:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;{
  &amp;quot;timestamp&amp;quot;: &amp;quot;2026-05-05T08:00:00Z&amp;quot;,
  &amp;quot;service&amp;quot;: &amp;quot;marending-dev&amp;quot;,
  &amp;quot;severity&amp;quot;: &amp;quot;error&amp;quot;,
  &amp;quot;message&amp;quot;: &amp;quot;User Bobby Tables doesn&amp;#39;t exist&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But then I want to track more information.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;{
  &amp;quot;timestamp&amp;quot;: &amp;quot;2026-05-05T08:00:00Z&amp;quot;,
  &amp;quot;service&amp;quot;: &amp;quot;marending-dev&amp;quot;,
  &amp;quot;severity&amp;quot;: &amp;quot;error&amp;quot;,
  &amp;quot;message&amp;quot;: &amp;quot;User Bobby Tables doesn&amp;#39;t exist&amp;quot;,
  &amp;quot;user_id&amp;quot;: &amp;quot;null&amp;quot;,
  &amp;quot;ip&amp;quot;: &amp;quot;85.0.1.42&amp;quot;,
  &amp;quot;path&amp;quot;: &amp;quot;/user/login&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You get the picture. The question is, how can we store these JSON blobs such that we can then
efficiently compute, say, error rate grouped by &lt;code&gt;user_id&lt;/code&gt;? We can’t have a static schema, since we
might want to add properties to this event at any time. We &lt;em&gt;can&lt;/em&gt; chuck this as a JSON string into
any database, file format or what have you. But then any query that attempts to read from properties
of that JSON string is going to incur the significant cost of extracting the appropriate fields for
every row of interest. One way to get reasonable performance here is to ensure severity and user_id
are each contiguously laid out so that they can be efficiently aggregated.&lt;/p&gt;
&lt;h2 id=&quot;parquet-and-variant&quot;&gt;Parquet and Variant&lt;/h2&gt;
&lt;p&gt;That brings us to &lt;a href=&quot;https://parquet.apache.org/&quot;&gt;Apache Parquet&lt;/a&gt;. Parquet is a column-oriented file
format. While DuckDB has its own file format for storing data, one of its strengths is its support
for a wide variety of other formats, including Parquet. So you can write and query Parquet files
natively with SQL within DuckDB.&lt;/p&gt;
&lt;p&gt;Recently Parquet added the Variant type, a binary encoding of primitives, arrays or objects of
arbitrary types. It allows representing structured data in such a way that it can be queried more
efficiently than if the same data was serialized and stored as a string type. But the real kicker is
“Variant shredding”. It’s the process of extracting some of the properties within the Variant into
their own columns. This should then give us maximum performance when aggregating on those columns.
And DuckDB apparently supports this!&lt;/p&gt;
&lt;p&gt;Once I learned about this, a path towards my dream observability system emerged. Step 1: Write a
service that accepts arbitrary JSON payloads, buffers them in memory and writes them to Parquet
files with the payload being of type Variant every 10 seconds or so. Step 2: Every hour, read all
the small Parquet files and &lt;strong&gt;shred&lt;/strong&gt; them into a larger hourly file. Step 3: Possibly aggregate the
hourly files into even broader files, but I’ll see when I get to it.&lt;/p&gt;
&lt;p&gt;Now at query time, I will have at most an hour’s worth of data in inefficient, &lt;em&gt;unshredded&lt;/em&gt; form.
That should be easy enough to deal with. Data older than that will be in highly efficient form. And
the best part? DuckDB should be able to query across many Parquet files using glob syntax, even when
some have concrete, shredded columns and some don’t. But we’ll see this for ourselves.&lt;/p&gt;
&lt;h2 id=&quot;exploration&quot;&gt;Exploration&lt;/h2&gt;
&lt;p&gt;Let’s start with the example given in DuckDB’s documentation.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE events (id INTEGER, data VARIANT);
INSERT INTO events VALUES
    (1, 42::VARIANT),
    (2, &amp;#39;hello world&amp;#39;::VARIANT),
    (3, [1, 2, 3]::VARIANT),
    (4, {&amp;#39;name&amp;#39;: &amp;#39;Alice&amp;#39;, &amp;#39;age&amp;#39;: 30}::VARIANT);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Querying the table presents the expected result.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT * FROM events;
┌───────┬────────────────────────────┐
│  id   │            data            │
│ int32 │          variant           │
├───────┼────────────────────────────┤
│     1 │ 42                         │
│     2 │ hello world                │
│     3 │ [1, 2, 3]                  │
│     4 │ {&amp;#39;name&amp;#39;: Alice, &amp;#39;age&amp;#39;: 30} │
└───────┴────────────────────────────┘

SELECT id, data, variant_typeof(data) AS vtype FROM events;
┌───────┬────────────────────────────┬───────────────────┐
│  id   │            data            │       vtype       │
│ int32 │          variant           │      varchar      │
├───────┼────────────────────────────┼───────────────────┤
│     1 │ 42                         │ INT32             │
│     2 │ hello world                │ VARCHAR           │
│     3 │ [1, 2, 3]                  │ ARRAY(3)          │
│     4 │ {&amp;#39;name&amp;#39;: Alice, &amp;#39;age&amp;#39;: 30} │ OBJECT(name, age) │
└───────┴────────────────────────────┴───────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now also query nested fields in a Variant.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT data.name FROM events;
┌─────────┐
│  name   │
│ variant │
├─────────┤
│ NULL    │
│ NULL    │
│ NULL    │
│ Alice   │
└─────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Writing this data as-is to a parquet file is now as simple as
&lt;code&gt;COPY events TO &amp;#39;events.parquet&amp;#39; (FORMAT parquet);&lt;/code&gt;. And indeed, having DuckDB describe the parquet
file’s shape yields the expected.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;DESCRIBE SELECT * FROM &amp;#39;events.parquet&amp;#39;;
┌────────────────┐
│ events.parquet │
│                │
│ id     integer │
│ data   variant │
└────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But hold on. According to the docs, writing to a Parquet file should automatically shred the variant
type. Shouldn’t I see the individual columns then? Turns out shredding is only a storage detail, not
a logical schema change. The type stays as is. To see how the data is actually stored, we have to
inspect the file’s metadata.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT name, type, converted_type FROM parquet_schema(&amp;#39;events.parquet&amp;#39;);
┌───────────────┬────────────┬────────────────┐
│     name      │    type    │ converted_type │
│    varchar    │  varchar   │    varchar     │
├───────────────┼────────────┼────────────────┤
│ duckdb_schema │ NULL       │ NULL           │
│ id            │ INT32      │ INT_32         │
│ data          │ NULL       │ NULL           │
│ metadata      │ BYTE_ARRAY │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ INT32      │ INT_32         │
└───────────────┴────────────┴────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Well, the way this is supposed to be read is as follows. &lt;code&gt;id&lt;/code&gt; is of type &lt;code&gt;INT32&lt;/code&gt;, the &lt;code&gt;data&lt;/code&gt; variant
itself doesn’t really have a type but it comprises three parts: &lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt; and
&lt;code&gt;typed_value&lt;/code&gt;. Of interest are &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;typed_value&lt;/code&gt;. Each row with the &lt;code&gt;data&lt;/code&gt; logical column is
either going to have the (fallback) byte-array &lt;code&gt;value&lt;/code&gt; representation set, or the shredded
&lt;code&gt;typed_value&lt;/code&gt; column. In this case, DuckDB’s autoshredder apparently decided that row 1 with the
integer data variant deserves to have its representation shredded, but not the other rows.&lt;/p&gt;
&lt;p&gt;If, instead, we instruct DuckDB to shred data to a struct like so&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY events TO &amp;#39;events.parquet&amp;#39; (
    FORMAT parquet,
    SHREDDING {&amp;#39;data&amp;#39;: &amp;#39;STRUCT(name VARCHAR, age INTEGER)&amp;#39;}
);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;we get this&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT name, type, converted_type, logical_type FROM parquet_schema(&amp;#39;events.parquet&amp;#39;);
┌───────────────┬────────────┬────────────────┐
│     name      │    type    │ converted_type │
│    varchar    │  varchar   │    varchar     │
├───────────────┼────────────┼────────────────┤
│ duckdb_schema │ NULL       │ NULL           │
│ id            │ INT32      │ INT_32         │
│ data          │ NULL       │ NULL           │
│ metadata      │ BYTE_ARRAY │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ NULL       │ NULL           │
│ name          │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ BYTE_ARRAY │ UTF8           │
│ age           │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ INT32      │ INT_32         │
└───────────────┴────────────┴────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we see &lt;code&gt;data&lt;/code&gt; with its &lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;typed_value&lt;/code&gt; properties and also &lt;code&gt;value&lt;/code&gt; and
&lt;code&gt;typed_value&lt;/code&gt; logical columns for both &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;age&lt;/code&gt;. For these the shredded types are UTF8 and
INT32 respectively.&lt;/p&gt;
&lt;p&gt;To get an intuitive understanding, it is interesting to look at the output of
&lt;code&gt;parquet-tools meta events.parquet&lt;/code&gt; from &lt;a href=&quot;https://github.com/hangxie/parquet-tools&quot;&gt;this tool&lt;/a&gt;:&lt;/p&gt;
&lt;details&gt;&lt;summary&gt;Output&lt;/summary&gt;&lt;div&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;{
  &amp;quot;NumRowGroups&amp;quot;: 1,
  &amp;quot;RowGroups&amp;quot;: [
    {
      &amp;quot;NumRows&amp;quot;: 4,
      &amp;quot;TotalByteSize&amp;quot;: 725,
      &amp;quot;Columns&amp;quot;: [
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;id&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;INT32&amp;quot;,
          &amp;quot;ConvertedType&amp;quot;: &amp;quot;convertedtype=INT_32&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 41,
          &amp;quot;UncompressedSize&amp;quot;: 39,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 0,
          &amp;quot;MaxValue&amp;quot;: 4,
          &amp;quot;MinValue&amp;quot;: 1,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        },
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;data&amp;quot;, &amp;quot;metadata&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;BYTE_ARRAY&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 52,
          &amp;quot;UncompressedSize&amp;quot;: 60,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 0,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        },
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;data&amp;quot;, &amp;quot;value&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;BYTE_ARRAY&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 81,
          &amp;quot;UncompressedSize&amp;quot;: 137,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 1,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        },
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;data&amp;quot;, &amp;quot;typed_value&amp;quot;, &amp;quot;name&amp;quot;, &amp;quot;value&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;BYTE_ARRAY&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 34,
          &amp;quot;UncompressedSize&amp;quot;: 119,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 4,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        },
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;data&amp;quot;, &amp;quot;typed_value&amp;quot;, &amp;quot;name&amp;quot;, &amp;quot;typed_value&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;BYTE_ARRAY&amp;quot;,
          &amp;quot;ConvertedType&amp;quot;: &amp;quot;convertedtype=UTF8&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 44,
          &amp;quot;UncompressedSize&amp;quot;: 128,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 3,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        },
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;data&amp;quot;, &amp;quot;typed_value&amp;quot;, &amp;quot;age&amp;quot;, &amp;quot;value&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;BYTE_ARRAY&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 34,
          &amp;quot;UncompressedSize&amp;quot;: 119,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 4,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        },
        {
          &amp;quot;PathInSchema&amp;quot;: [&amp;quot;data&amp;quot;, &amp;quot;typed_value&amp;quot;, &amp;quot;age&amp;quot;, &amp;quot;typed_value&amp;quot;],
          &amp;quot;Type&amp;quot;: &amp;quot;INT32&amp;quot;,
          &amp;quot;ConvertedType&amp;quot;: &amp;quot;convertedtype=INT_32&amp;quot;,
          &amp;quot;Encodings&amp;quot;: [&amp;quot;PLAIN&amp;quot;],
          &amp;quot;CompressedSize&amp;quot;: 39,
          &amp;quot;UncompressedSize&amp;quot;: 123,
          &amp;quot;NumValues&amp;quot;: 4,
          &amp;quot;NullCount&amp;quot;: 3,
          &amp;quot;CompressionCodec&amp;quot;: &amp;quot;SNAPPY&amp;quot;
        }
      ]
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/details&gt;
&lt;p&gt;Let’s take this line for example &lt;code&gt;[&amp;quot;data&amp;quot;, &amp;quot;typed_value&amp;quot;, &amp;quot;name&amp;quot;, &amp;quot;typed_value&amp;quot;]&lt;/code&gt;. A row in our
dataset is going to populate this logical column when the &lt;code&gt;name&lt;/code&gt; property in the variant is a UTF8
string, otherwise it will fall back to the BYTE_ARRAY &lt;code&gt;data.value&lt;/code&gt;. And indeed we see that 3 out of
4 values in this column are NULL, since row 4 is the only one that can populate this &lt;code&gt;typed_value&lt;/code&gt;.
Conversely, the &lt;code&gt;[&amp;quot;data&amp;quot;, &amp;quot;value&amp;quot;]&lt;/code&gt; entry only has a single NULL value: only row 4 does not populate
the fallback column, populating the typed values instead.&lt;/p&gt;
&lt;p&gt;If we look at &lt;code&gt;[&amp;quot;data&amp;quot;, &amp;quot;typed_value&amp;quot;, &amp;quot;name&amp;quot;, &amp;quot;value&amp;quot;]&lt;/code&gt; we can see that not a single non-NULL value
is here. I suppose this would be populated if we had a row with an object that contains the &lt;code&gt;name&lt;/code&gt;
property, but with a type other than string.&lt;/p&gt;
&lt;p&gt;I’ll just leave
&lt;a href=&quot;https://parquet.apache.org/blog/2026/02/27/variant-type-in-apache-parquet-for-semi-structured-data/&quot;&gt;this blog post&lt;/a&gt;
from the official Parquet website here for further reading. It has some nice visualizations that
further explain how this works.&lt;/p&gt;
&lt;h2 id=&quot;autoshredding&quot;&gt;Autoshredding&lt;/h2&gt;
&lt;p&gt;So far, so good. But we don’t want to have to specify how the values should be shredded. The whole
point would be for DuckDB to figure it out from the data, but somehow in our first attempt it just
chose to “materialize” the integer and not our object.&lt;/p&gt;
&lt;p&gt;Well, it looks like DuckDB decides what to shred based on how often a specific type occurs in the
table. If I add two more rows that have the same shape as the object in row 4&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;INSERT INTO events VALUES (5, {&amp;#39;name&amp;#39;: &amp;#39;Bob&amp;#39;, &amp;#39;age&amp;#39;: 27}::VARIANT);
INSERT INTO events VALUES (6, {&amp;#39;name&amp;#39;: &amp;#39;Flo&amp;#39;, &amp;#39;age&amp;#39;: 31}::VARIANT);
COPY events TO &amp;#39;events-more-structs.parquet&amp;#39; (FORMAT parquet);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I get our familiar shredding, this time without having to specify the type.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT name, type, converted_type FROM parquet_schema(&amp;#39;events-more-structs.parquet&amp;#39;);
┌───────────────┬────────────┬────────────────┐
│     name      │    type    │ converted_type │
│    varchar    │  varchar   │    varchar     │
├───────────────┼────────────┼────────────────┤
│ duckdb_schema │ NULL       │ NULL           │
│ id            │ INT32      │ INT_32         │
│ data          │ NULL       │ NULL           │
│ metadata      │ BYTE_ARRAY │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ NULL       │ NULL           │
│ age           │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ INT32      │ INT_32         │
│ name          │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ BYTE_ARRAY │ UTF8           │
└───────────────┴────────────┴────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I add a bunch more string columns now, I can get the autoshredder to only shred the UTF8 type.
Initially I would have expected that it should be possible to have both shredded &lt;code&gt;age&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt;
values as well as a fallback not to an untyped BYTE_ARRAY but to a UTF8 string. But thinking about
this, I guess the &lt;code&gt;typed_value&lt;/code&gt; for data is now an object of &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;age&lt;/code&gt; (although it’s not
readily visible in the above representation), so it can’t be a string simultaneously.&lt;/p&gt;
&lt;p&gt;It should be possible, however, if the string columns were in a new property inside an object. Let
me give this a shot.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;INSERT INTO events VALUES (10, {&amp;#39;message&amp;#39;: &amp;#39;Hello world&amp;#39;}::VARIANT);
INSERT INTO events VALUES (11, {&amp;#39;message&amp;#39;: &amp;#39;message&amp;#39;}::VARIANT);
INSERT INTO events VALUES (12, {&amp;#39;message&amp;#39;: &amp;#39;42&amp;#39;}::VARIANT);

COPY events TO &amp;#39;events-message.parquet&amp;#39; (FORMAT parquet);
SELECT name, type, converted_type FROM parquet_schema(&amp;#39;events-message.parquet&amp;#39;);
┌───────────────┬────────────┬────────────────┐
│     name      │    type    │ converted_type │
│    varchar    │  varchar   │    varchar     │
├───────────────┼────────────┼────────────────┤
│ duckdb_schema │ NULL       │ NULL           │
│ id            │ INT32      │ INT_32         │
│ data          │ NULL       │ NULL           │
│ metadata      │ BYTE_ARRAY │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ NULL       │ NULL           │
│ message       │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ BYTE_ARRAY │ UTF8           │
│ age           │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ INT32      │ INT_32         │
│ name          │ NULL       │ NULL           │
│ value         │ BYTE_ARRAY │ NULL           │
│ typed_value   │ BYTE_ARRAY │ UTF8           │
└───────────────┴────────────┴────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Indeed we can see that it now materialized &lt;code&gt;age&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;message&lt;/code&gt;. Neat!&lt;/p&gt;
&lt;h2 id=&quot;real-world-data&quot;&gt;Real-world data&lt;/h2&gt;
&lt;p&gt;With my mental model starting to get less foggy, I think it’s time to start digging into some
real-world data from my existing observability setup. Luckily ClickHouse makes it really easy to get
my data out: &lt;code&gt;SELECT * FROM events.otel_traces INTO OUTFILE &amp;#39;./otel_traces.parquet&amp;#39; FORMAT Parquet&lt;/code&gt;.
This dumps all of the data into a single Parquet file with just over 10 million rows, weighing in at
800 MB.&lt;/p&gt;
&lt;p&gt;The shape of this file is determined by the standard schema the OpenTelemetry collector created when
I started ingesting data. In ClickHouse it looks like this:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE default.otel_traces
(
        `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        `TraceId` String CODEC(ZSTD(1)),
        `SpanId` String CODEC(ZSTD(1)),
        `ParentSpanId` String CODEC(ZSTD(1)),
        `TraceState` String CODEC(ZSTD(1)),
        `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
        `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
        `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
        `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `ScopeName` String CODEC(ZSTD(1)),
        `ScopeVersion` String CODEC(ZSTD(1)),
        `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `Duration` Int64 CODEC(ZSTD(1)),
        `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
        `StatusMessage` String CODEC(ZSTD(1)),
        `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
        `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
        `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
        `Links.TraceId` Array(String) CODEC(ZSTD(1)),
        `Links.SpanId` Array(String) CODEC(ZSTD(1)),
        `Links.TraceState` Array(String) CODEC(ZSTD(1)),
        `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
        INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
        INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_duration Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(3)
SETTINGS ttl_only_drop_parts = 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once dumped to the Parquet file, we can see the shape as read by DuckDB here:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;DESCRIBE SELECT * FROM &amp;#39;otel_traces.parquet&amp;#39;;
┌───────────────────────────────────────────────┐
│              otel_traces.parquet              │
│                                               │
│ Timestamp          timestamp with time zone   │
│ TraceId            varchar                    │
│ SpanId             varchar                    │
│ ParentSpanId       varchar                    │
│ TraceState         varchar                    │
│ SpanName           varchar                    │
│ SpanKind           varchar                    │
│ ServiceName        varchar                    │
│ ResourceAttributes map(varchar, varchar)      │
│ ScopeName          varchar                    │
│ ScopeVersion       varchar                    │
│ SpanAttributes     map(varchar, varchar)      │
│ Duration           ubigint                    │
│ StatusCode         varchar                    │
│ StatusMessage      varchar                    │
│ Events.Timestamp   timestamp with time zone[] │
│ Events.Name        varchar[]                  │
│ Events.Attributes  map(varchar, varchar)[]    │
│ Links.TraceId      varchar[]                  │
│ Links.SpanId       varchar[]                  │
│ Links.TraceState   varchar[]                  │
│ Links.Attributes   map(varchar, varchar)[]    │
└───────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can see a clear correspondence between the Parquet and ClickHouse types. Notably, this file is
not in the shape we want yet. For this we’ll need to slice and dice it into different forms to
benchmark how queries behave.&lt;/p&gt;
&lt;h2 id=&quot;benchmarks&quot;&gt;Benchmarks&lt;/h2&gt;
&lt;p&gt;To get an overview of the performance of the Variant type, I’m going to wrangle the payload into the
following types to see how the different approaches stack up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JSON&lt;/li&gt;
&lt;li&gt;Map / Array&lt;/li&gt;
&lt;li&gt;Variant&lt;/li&gt;
&lt;li&gt;Fully materialized&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For simplicity, I’m going to focus on two types of data I have stored in my system, GPS location
data and access logs to this website. For both datasets I’ll run a query that can benefit from
prefiltering data down to the relevant rows and another one that has to run expensive aggregations
across the whole dataset.&lt;/p&gt;
&lt;p&gt;For the GPS data, the first query should get distinct days I’ve visited a particular location. The
second query will get average speed and altitude hourly across the whole timespan.&lt;/p&gt;
&lt;p&gt;For the access logs, the first query will count error responses per URI, and the second one will
aggregate daily traffic per browser.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Danger zone! The queries compared here were generated / translated by Claude and may not be the
pinnacle of optimization.&lt;/p&gt;&lt;/div&gt;
&lt;details&gt;&lt;summary&gt;GPS details&lt;/summary&gt;&lt;div&gt;&lt;h4 id=&quot;json&quot;&gt;JSON&lt;/h4&gt;&lt;p&gt;This nifty query gets the relevant data from my GPS entries and stores it into a new Parquet file
with the data stored as JSON.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
    SELECT
    Timestamp,
    &amp;#39;gps-location&amp;#39; AS service,
    CAST(SpanAttributes AS JSON) AS data
    FROM &amp;#39;otel_traces.parquet&amp;#39;
    WHERE SpanName = &amp;#39;gps-location&amp;#39;
) TO &amp;#39;gps_location_json.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;gps_location_json.parquet&amp;#39;;
┌────────────────────────────────────┐
│        gps_location.parquet        │
│                                    │
│ Timestamp timestamp with time zone │
│ service   varchar                  │
│ data      json                     │
└────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The nested structures inside the data field are unfortunately not parsed into JSON as well, but
rather stay as string values of the top-level properties. But as the DuckDB JSON type is anyhow just
backed by a VARCHAR, I don’t expect too much impact for this.&lt;/p&gt;&lt;p&gt;Now here is the query to list distinct days where I’ve visited a particular location. &lt;em&gt;Pasting these
coordinates into a map is left as an exercise to the reader.&lt;/em&gt;&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT DISTINCT
  DATE_TRUNC(&amp;#39;day&amp;#39;,
    CAST(json_extract_string(
           CAST(json_extract_string(data, &amp;#39;$.location&amp;#39;) AS JSON),
           &amp;#39;$.properties.timestamp&amp;#39;
         ) AS TIMESTAMP)
  ) AS day
FROM &amp;#39;gps_location_json.parquet&amp;#39;
WHERE CAST(json_extract(
        CAST(json_extract_string(data, &amp;#39;$.location&amp;#39;) AS JSON),
        &amp;#39;$.geometry.coordinates[0]&amp;#39;
      ) AS DOUBLE) BETWEEN 8.486986 AND 8.493896
  AND CAST(json_extract(
        CAST(json_extract_string(data, &amp;#39;$.location&amp;#39;) AS JSON),
        &amp;#39;$.geometry.coordinates[1]&amp;#39;
      ) AS DOUBLE) BETWEEN 47.348284 AND 47.353111
ORDER BY day DESC;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The second query to aggregate some data into hourly buckets.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;WITH g AS (
  SELECT
    CAST(json_extract_string(data, &amp;#39;$.location&amp;#39;) AS JSON) AS loc
  FROM &amp;#39;gps_location_json.parquet&amp;#39;
)
SELECT
  TIME_BUCKET(INTERVAL &amp;#39;1 hour&amp;#39;,
    CAST(json_extract_string(loc, &amp;#39;$.properties.timestamp&amp;#39;) AS TIMESTAMP)
  ) AS hour,
  AVG(CAST(json_extract(loc, &amp;#39;$.properties.speed&amp;#39;) AS DOUBLE))               AS avg_speed,
  AVG(CAST(json_extract(loc, &amp;#39;$.properties.altitude&amp;#39;) AS DOUBLE))            AS avg_altitude,
  MAX(CAST(json_extract(loc, &amp;#39;$.properties.horizontal_accuracy&amp;#39;) AS DOUBLE)) AS worst_accuracy,
  COUNT(*) AS points
FROM g
GROUP BY hour
ORDER BY hour;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Note how in those two queries we use the nested &lt;code&gt;timestamp&lt;/code&gt; from within the payload instead of the
top-level &lt;code&gt;Timestamp&lt;/code&gt;. That’s due to getting those proper timestamps messed up when migrating data
from an even older system into the one I use currently. If what I’m trying here comes to fruition,
I’m going to fix this up.&lt;/p&gt;&lt;p&gt;The benchmark results will follow at the end.&lt;/p&gt;&lt;h4 id=&quot;map&quot;&gt;Map&lt;/h4&gt;&lt;p&gt;For the Map type I could just leave the data as-is, since the original Parquet file already has the
&lt;code&gt;SpanAttributes&lt;/code&gt; property as &lt;code&gt;map(varchar, varchar)&lt;/code&gt;. But due to the nesting of the data I care
about, this would actually end up being the JSON type just with a detour. Instead, I’m flattening
the structure into a proper Map below. Of course, this kind of defeats the purpose of not having to
care about the exact schema of the data coming my way. But for benchmarking the data types it will
do.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  WITH src AS (
    SELECT
      Timestamp,
      CAST(SpanAttributes[&amp;#39;location&amp;#39;] AS JSON) AS loc
    FROM &amp;#39;otel_traces.parquet&amp;#39;
    WHERE SpanName = &amp;#39;gps-location&amp;#39;
  )
  SELECT
    Timestamp,
    &amp;#39;gps-location&amp;#39; AS service,
    MAP {
      &amp;#39;location.type&amp;#39;:                           json_extract_string(loc, &amp;#39;$.type&amp;#39;),
      &amp;#39;location.geometry.type&amp;#39;:                  json_extract_string(loc, &amp;#39;$.geometry.type&amp;#39;),
      &amp;#39;location.geometry.coordinates[0]&amp;#39;:        json_extract_string(loc, &amp;#39;$.geometry.coordinates[0]&amp;#39;),
      &amp;#39;location.geometry.coordinates[1]&amp;#39;:        json_extract_string(loc, &amp;#39;$.geometry.coordinates[1]&amp;#39;),
      &amp;#39;location.properties.altitude&amp;#39;:            json_extract_string(loc, &amp;#39;$.properties.altitude&amp;#39;),
      &amp;#39;location.properties.battery_level&amp;#39;:       json_extract_string(loc, &amp;#39;$.properties.battery_level&amp;#39;),
      &amp;#39;location.properties.battery_state&amp;#39;:       json_extract_string(loc, &amp;#39;$.properties.battery_state&amp;#39;),
      &amp;#39;location.properties.course&amp;#39;:              json_extract_string(loc, &amp;#39;$.properties.course&amp;#39;),
      &amp;#39;location.properties.course_accuracy&amp;#39;:     json_extract_string(loc, &amp;#39;$.properties.course_accuracy&amp;#39;),
      &amp;#39;location.properties.horizontal_accuracy&amp;#39;: json_extract_string(loc, &amp;#39;$.properties.horizontal_accuracy&amp;#39;),
      &amp;#39;location.properties.motion[0]&amp;#39;:           json_extract_string(loc, &amp;#39;$.properties.motion[0]&amp;#39;),
      &amp;#39;location.properties.speed&amp;#39;:               json_extract_string(loc, &amp;#39;$.properties.speed&amp;#39;),
      &amp;#39;location.properties.speed_accuracy&amp;#39;:      json_extract_string(loc, &amp;#39;$.properties.speed_accuracy&amp;#39;),
      &amp;#39;location.properties.timestamp&amp;#39;:           json_extract_string(loc, &amp;#39;$.properties.timestamp&amp;#39;),
      &amp;#39;location.properties.vertical_accuracy&amp;#39;:   json_extract_string(loc, &amp;#39;$.properties.vertical_accuracy&amp;#39;),
      &amp;#39;location.properties.wifi&amp;#39;:                json_extract_string(loc, &amp;#39;$.properties.wifi&amp;#39;)
    } AS data
  FROM src
) TO &amp;#39;gps_location_map.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;gps_location_map.parquet&amp;#39;;
┌────────────────────────────────────┐
│      gps_location_map.parquet      │
│                                    │
│ Timestamp timestamp with time zone │
│ service   varchar                  │
│ data      map(varchar, varchar)    │
└────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT DISTINCT
  DATE_TRUNC(&amp;#39;day&amp;#39;, CAST(data[&amp;#39;location.properties.timestamp&amp;#39;] AS TIMESTAMP)) AS day
FROM &amp;#39;gps_location_map.parquet&amp;#39;
WHERE CAST(data[&amp;#39;location.geometry.coordinates[0]&amp;#39;] AS DOUBLE)
        BETWEEN 8.486986 AND 8.493896
  AND CAST(data[&amp;#39;location.geometry.coordinates[1]&amp;#39;] AS DOUBLE)
        BETWEEN 47.348284 AND 47.353111
ORDER BY day DESC;&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  TIME_BUCKET(INTERVAL &amp;#39;1 hour&amp;#39;,
    CAST(data[&amp;#39;location.properties.timestamp&amp;#39;] AS TIMESTAMP)) AS hour,
  AVG(CAST(data[&amp;#39;location.properties.speed&amp;#39;] AS DOUBLE))               AS avg_speed,
  AVG(CAST(data[&amp;#39;location.properties.altitude&amp;#39;] AS DOUBLE))            AS avg_altitude,
  MAX(CAST(data[&amp;#39;location.properties.horizontal_accuracy&amp;#39;] AS DOUBLE)) AS worst_accuracy,
  COUNT(*) AS points
FROM &amp;#39;gps_location_map.parquet&amp;#39;
GROUP BY hour
ORDER BY hour;&lt;/code&gt;&lt;/pre&gt;&lt;h4 id=&quot;variant&quot;&gt;Variant&lt;/h4&gt;&lt;p&gt;Finally, let’s get to the real meat of the story. Here we store the gps location data into a Parquet
file where the relevant data is stored as a Variant.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  SELECT
    Timestamp,
    &amp;#39;gps-location&amp;#39; AS service,
    CAST(CAST(SpanAttributes[&amp;#39;location&amp;#39;] AS JSON) AS VARIANT) AS data
  FROM &amp;#39;otel_traces.parquet&amp;#39;
  WHERE SpanName = &amp;#39;gps-location&amp;#39;
) TO &amp;#39;gps_location_variant.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;gps_location_variant.parquet&amp;#39;;
┌────────────────────────────────────┐
│    gps_location_variant.parquet    │
│                                    │
│ Timestamp timestamp with time zone │
│ service   varchar                  │
│ data      variant                  │
└────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT DISTINCT
  DATE_TRUNC(&amp;#39;day&amp;#39;, CAST(data.properties.timestamp AS TIMESTAMP)) AS day
FROM &amp;#39;gps_location_variant.parquet&amp;#39;
WHERE CAST(data.geometry.coordinates[1] AS DOUBLE) BETWEEN 8.486986 AND 8.493896
  AND CAST(data.geometry.coordinates[2] AS DOUBLE) BETWEEN 47.348284 AND 47.353111
ORDER BY day DESC;&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  TIME_BUCKET(INTERVAL &amp;#39;1 hour&amp;#39;,
    CAST(data.properties.timestamp AS TIMESTAMP)) AS hour,
  AVG(CAST(data.properties.speed AS DOUBLE))               AS avg_speed,
  AVG(CAST(data.properties.altitude AS DOUBLE))            AS avg_altitude,
  MAX(CAST(data.properties.horizontal_accuracy AS DOUBLE)) AS worst_accuracy,
  COUNT(*) AS points
FROM &amp;#39;gps_location_variant.parquet&amp;#39;
GROUP BY hour
ORDER BY hour;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The double casting to JSON and then Variant is apparently doing the heavy lifting to ensure nested
values are also properly typed. I can confirm that everything is nicely shredded after I save this
file.&lt;/p&gt;&lt;h4 id=&quot;materialized&quot;&gt;Materialized&lt;/h4&gt;&lt;p&gt;This one again is not going to be an actual option for my use case as I need to know the schema up
front, but it serves as a comparison.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  WITH src AS (
    SELECT
      Timestamp,
      CAST(SpanAttributes[&amp;#39;location&amp;#39;] AS JSON) AS loc
    FROM &amp;#39;otel_traces.parquet&amp;#39;
    WHERE SpanName = &amp;#39;gps-location&amp;#39;
  )
  SELECT
    Timestamp,
    &amp;#39;gps-location&amp;#39; AS service,
    json_extract_string(loc, &amp;#39;$.type&amp;#39;)                                      AS location_type,
    json_extract_string(loc, &amp;#39;$.geometry.type&amp;#39;)                             AS geometry_type,
    CAST(json_extract(loc, &amp;#39;$.geometry.coordinates[0]&amp;#39;) AS DOUBLE)          AS lon,
    CAST(json_extract(loc, &amp;#39;$.geometry.coordinates[1]&amp;#39;) AS DOUBLE)          AS lat,
    CAST(json_extract(loc, &amp;#39;$.properties.altitude&amp;#39;) AS DOUBLE)              AS altitude,
    CAST(json_extract(loc, &amp;#39;$.properties.battery_level&amp;#39;) AS DOUBLE)         AS battery_level,
    json_extract_string(loc, &amp;#39;$.properties.battery_state&amp;#39;)                  AS battery_state,
    CAST(json_extract(loc, &amp;#39;$.properties.course&amp;#39;) AS DOUBLE)                AS course,
    CAST(json_extract(loc, &amp;#39;$.properties.course_accuracy&amp;#39;) AS DOUBLE)       AS course_accuracy,
    CAST(json_extract(loc, &amp;#39;$.properties.horizontal_accuracy&amp;#39;) AS DOUBLE)   AS horizontal_accuracy,
    json_extract_string(loc, &amp;#39;$.properties.motion[0]&amp;#39;)                      AS motion,
    CAST(json_extract(loc, &amp;#39;$.properties.speed&amp;#39;) AS DOUBLE)                 AS speed,
    CAST(json_extract(loc, &amp;#39;$.properties.speed_accuracy&amp;#39;) AS DOUBLE)        AS speed_accuracy,
    CAST(json_extract_string(loc, &amp;#39;$.properties.timestamp&amp;#39;) AS TIMESTAMP)   AS gps_timestamp,
    CAST(json_extract(loc, &amp;#39;$.properties.vertical_accuracy&amp;#39;) AS DOUBLE)     AS vertical_accuracy,
    json_extract_string(loc, &amp;#39;$.properties.wifi&amp;#39;)                           AS wifi
  FROM src
) TO &amp;#39;gps_location_materialized.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;gps_location_materialized.parquet&amp;#39;;
┌──────────────────────────────────────────────┐
│      gps_location_materialized.parquet       │
│                                              │
│ Timestamp           timestamp with time zone │
│ service             varchar                  │
│ location_type       varchar                  │
│ geometry_type       varchar                  │
│ lon                 double                   │
│ lat                 double                   │
│ altitude            double                   │
│ battery_level       double                   │
│ battery_state       varchar                  │
│ course              double                   │
│ course_accuracy     double                   │
│ horizontal_accuracy double                   │
│ motion              varchar                  │
│ speed               double                   │
│ speed_accuracy      double                   │
│ gps_timestamp       timestamp                │
│ vertical_accuracy   double                   │
│ wifi                varchar                  │
└──────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The query here is very straight-forward.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT DISTINCT DATE_TRUNC(&amp;#39;day&amp;#39;, gps_timestamp) AS day
FROM &amp;#39;gps_location_materialized.parquet&amp;#39;
WHERE lon BETWEEN 8.486986 AND 8.493896
  AND lat BETWEEN 47.348284 AND 47.353111
ORDER BY day DESC;&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  TIME_BUCKET(INTERVAL &amp;#39;1 hour&amp;#39;, gps_timestamp) AS hour,
  AVG(speed)               AS avg_speed,
  AVG(altitude)            AS avg_altitude,
  MAX(horizontal_accuracy) AS worst_accuracy,
  COUNT(*) AS points
FROM &amp;#39;gps_location_materialized.parquet&amp;#39;
GROUP BY hour
ORDER BY hour;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/details&gt;
&lt;details&gt;&lt;summary&gt;Access logs details&lt;/summary&gt;&lt;div&gt;&lt;p&gt;Now let’s look at another dataset I have in my observability system: Access logs to this
website. I build this page into a static bundle which I then serve using a Rust service. The only
reason I have this service and reverse proxy via Caddy instead of just letting Caddy serve the
static page directly is exactly these access logs. Caddy doesn’t give you insights per domain, but
only aggregated metrics. So
&lt;a href=&quot;https://github.com/beingflo/marending.dev/blob/main/service/src/main.rs&quot;&gt;this code&lt;/a&gt; sets up an
OpenTelemetry exporter and then logs events
&lt;a href=&quot;https://github.com/beingflo/marending.dev/blob/main/service/src/main.rs#L67&quot;&gt;on request&lt;/a&gt; and
&lt;a href=&quot;https://github.com/beingflo/marending.dev/blob/main/service/src/main.rs#L85&quot;&gt;on response&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;Given the way OpenTelemetry ingests this data, the &lt;code&gt;Events.Attributes&lt;/code&gt; property takes the following
form. The two objects correspond to the two &lt;code&gt;info!&lt;/code&gt; invocations in the code.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;[
  {
    &amp;quot;code.filepath&amp;quot;: &amp;quot;src/main.rs&amp;quot;,
    &amp;quot;code.lineno&amp;quot;: &amp;quot;67&amp;quot;,
    &amp;quot;code.namespace&amp;quot;: &amp;quot;marending_dev&amp;quot;,
    &amp;quot;level&amp;quot;: &amp;quot;INFO&amp;quot;,
    &amp;quot;referrer&amp;quot;: &amp;quot;&amp;quot;,
    &amp;quot;request&amp;quot;: &amp;quot;HEAD&amp;quot;,
    &amp;quot;target&amp;quot;: &amp;quot;marending_dev&amp;quot;,
    &amp;quot;uri&amp;quot;: &amp;quot;/&amp;quot;,
    &amp;quot;user_agent&amp;quot;: &amp;quot;updown.io daemon 2.11&amp;quot;
  },
  {
    &amp;quot;code.filepath&amp;quot;: &amp;quot;src/main.rs&amp;quot;,
    &amp;quot;code.lineno&amp;quot;: &amp;quot;85&amp;quot;,
    &amp;quot;code.namespace&amp;quot;: &amp;quot;marending_dev&amp;quot;,
    &amp;quot;latency&amp;quot;: &amp;quot;424164&amp;quot;,
    &amp;quot;level&amp;quot;: &amp;quot;INFO&amp;quot;,
    &amp;quot;status&amp;quot;: &amp;quot;200&amp;quot;,
    &amp;quot;target&amp;quot;: &amp;quot;marending_dev&amp;quot;
  }
]&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&quot;json-1&quot;&gt;JSON&lt;/h3&gt;&lt;p&gt;Now, going through the same spiel again, we extract the data into a Parquet file with the relevant
fields cast to JSON.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  SELECT
    Timestamp,
    &amp;#39;marending&amp;#39; AS service,
    CAST(&amp;quot;Events.Attributes&amp;quot; AS JSON) AS data
  FROM &amp;#39;otel_traces.parquet&amp;#39;
  WHERE ServiceName = &amp;#39;marending&amp;#39;
) TO &amp;#39;marending_json.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;marending_json.parquet&amp;#39;;
┌────────────────────────────────────┐
│       marending_json.parquet       │
│                                    │
│ Timestamp timestamp with time zone │
│ service   varchar                  │
│ data      json                     │
└────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Here’s the first query that counts error responses by URI. The top result is &lt;code&gt;/robots.txt&lt;/code&gt; if you
care to know. &lt;code&gt;.env&lt;/code&gt; is not much further down the list though.&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  json_extract_string(data, &amp;#39;$[0].uri&amp;#39;) AS uri,
  COUNT(*) AS error_count
FROM &amp;#39;marending_json.parquet&amp;#39;
WHERE json_extract_string(data, &amp;#39;$[1].status&amp;#39;) LIKE &amp;#39;4%&amp;#39;
   OR json_extract_string(data, &amp;#39;$[1].status&amp;#39;) LIKE &amp;#39;5%&amp;#39;
GROUP BY uri
ORDER BY error_count DESC
LIMIT 50;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And the second query to get the daily traffic by browser:&lt;/p&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;WITH r AS (
  SELECT
    Timestamp,
    json_extract_string(data, &amp;#39;$[0].user_agent&amp;#39;) AS user_agent,
    CAST(json_extract(data, &amp;#39;$[1].latency&amp;#39;) AS DOUBLE) AS latency_us
  FROM &amp;#39;marending_json.parquet&amp;#39;
)
SELECT
  DATE_TRUNC(&amp;#39;day&amp;#39;, Timestamp) AS day,
  CASE
    WHEN user_agent LIKE &amp;#39;%Firefox%&amp;#39; THEN &amp;#39;Firefox&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Chrome%&amp;#39; AND user_agent NOT LIKE &amp;#39;%Edg%&amp;#39; THEN &amp;#39;Chrome&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Safari%&amp;#39; AND user_agent NOT LIKE &amp;#39;%Chrome%&amp;#39; THEN &amp;#39;Safari&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Edg%&amp;#39; THEN &amp;#39;Edge&amp;#39;
    WHEN user_agent LIKE &amp;#39;%bot%&amp;#39; OR user_agent LIKE &amp;#39;%Bot%&amp;#39; THEN &amp;#39;Bot&amp;#39;
    ELSE &amp;#39;Other&amp;#39;
  END AS browser,
  COUNT(*) AS requests,
  AVG(latency_us) AS avg_latency_us
FROM r
GROUP BY day, browser
ORDER BY day, requests DESC;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&quot;map-1&quot;&gt;Map&lt;/h3&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  SELECT
    Timestamp,
    &amp;#39;marending&amp;#39; AS service,
    &amp;quot;Events.Attributes&amp;quot; AS data
  FROM &amp;#39;otel_traces.parquet&amp;#39;
  WHERE ServiceName = &amp;#39;marending&amp;#39;
) TO &amp;#39;marending_map.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;marending_map.parquet&amp;#39;;
┌────────────────────────────────────┐
│       marending_map.parquet        │
│                                    │
│ Timestamp timestamp with time zone │
│ service   varchar                  │
│ data      map(varchar, varchar)[]  │
└────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  data[1][&amp;#39;uri&amp;#39;] AS uri,
  COUNT(*) AS error_count
FROM &amp;#39;marending_map.parquet&amp;#39;
WHERE data[2][&amp;#39;status&amp;#39;] LIKE &amp;#39;4%&amp;#39;
   OR data[2][&amp;#39;status&amp;#39;] LIKE &amp;#39;5%&amp;#39;
GROUP BY uri
ORDER BY error_count DESC
LIMIT 50;&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;WITH r AS (
  SELECT
    Timestamp,
    data[1][&amp;#39;user_agent&amp;#39;] AS user_agent,
    CAST(data[2][&amp;#39;latency&amp;#39;] AS DOUBLE) AS latency_us
  FROM &amp;#39;marending_map.parquet&amp;#39;
)
SELECT
  DATE_TRUNC(&amp;#39;day&amp;#39;, Timestamp) AS day,
  CASE
    WHEN user_agent LIKE &amp;#39;%Firefox%&amp;#39; THEN &amp;#39;Firefox&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Edg%&amp;#39;     THEN &amp;#39;Edge&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Chrome%&amp;#39;  THEN &amp;#39;Chrome&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Safari%&amp;#39;  THEN &amp;#39;Safari&amp;#39;
    WHEN user_agent ILIKE &amp;#39;%bot%&amp;#39;    THEN &amp;#39;Bot&amp;#39;
    ELSE &amp;#39;Other&amp;#39;
  END AS browser,
  COUNT(*) AS requests,
  AVG(latency_us) AS avg_latency_us
FROM r
GROUP BY day, browser
ORDER BY day, requests DESC;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&quot;variant-1&quot;&gt;Variant&lt;/h3&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  SELECT
    Timestamp,
    &amp;#39;marending&amp;#39; AS service,
    CAST(CAST(&amp;quot;Events.Attributes&amp;quot; AS JSON) AS VARIANT) AS data
  FROM &amp;#39;otel_traces.parquet&amp;#39;
  WHERE ServiceName = &amp;#39;marending&amp;#39;
) TO &amp;#39;marending_variant.parquet&amp;#39; (FORMAT PARQUET);

DESCRIBE SELECT * FROM &amp;#39;marending_variant.parquet&amp;#39;;
┌────────────────────────────────────┐
│     marending_variant.parquet      │
│                                    │
│ Timestamp timestamp with time zone │
│ service   varchar                  │
│ data      variant                  │
└────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  CAST(data[1].uri AS VARCHAR) AS uri,
  COUNT(*) AS error_count
FROM &amp;#39;marending_variant.parquet&amp;#39;
WHERE CAST(data[2].status AS VARCHAR) LIKE &amp;#39;4%&amp;#39;
   OR CAST(data[2].status AS VARCHAR) LIKE &amp;#39;5%&amp;#39;
GROUP BY uri
ORDER BY error_count DESC
LIMIT 50;&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;WITH r AS (
  SELECT
    Timestamp,
    CAST(data[1].user_agent AS VARCHAR) AS user_agent,
    CAST(data[2].latency AS DOUBLE) AS latency_us
  FROM &amp;#39;marending_variant.parquet&amp;#39;
)
SELECT
  DATE_TRUNC(&amp;#39;day&amp;#39;, Timestamp) AS day,
  CASE
    WHEN user_agent LIKE &amp;#39;%Firefox%&amp;#39; THEN &amp;#39;Firefox&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Edg%&amp;#39;     THEN &amp;#39;Edge&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Chrome%&amp;#39;  THEN &amp;#39;Chrome&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Safari%&amp;#39;  THEN &amp;#39;Safari&amp;#39;
    WHEN user_agent ILIKE &amp;#39;%bot%&amp;#39;    THEN &amp;#39;Bot&amp;#39;
    ELSE &amp;#39;Other&amp;#39;
  END AS browser,
  COUNT(*) AS requests,
  AVG(latency_us) AS avg_latency_us
FROM r
GROUP BY day, browser
ORDER BY day, requests DESC;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&quot;materialized-1&quot;&gt;Materialized&lt;/h3&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;COPY (
  SELECT
    Timestamp,
    &amp;#39;marending&amp;#39; AS service,
    -- Request event (index 1)
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;code.filepath&amp;#39;]  AS request_code_filepath,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;code.lineno&amp;#39;]    AS request_code_lineno,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;code.namespace&amp;#39;] AS request_code_namespace,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;level&amp;#39;]          AS request_level,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;referrer&amp;#39;]       AS referrer,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;request&amp;#39;]        AS request_method,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;target&amp;#39;]         AS request_target,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;uri&amp;#39;]            AS uri,
    &amp;quot;Events.Attributes&amp;quot;[1][&amp;#39;user_agent&amp;#39;]     AS user_agent,
    -- Response event (index 2)
    &amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;code.filepath&amp;#39;]            AS response_code_filepath,
    &amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;code.lineno&amp;#39;]              AS response_code_lineno,
    &amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;code.namespace&amp;#39;]           AS response_code_namespace,
    CAST(&amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;latency&amp;#39;] AS BIGINT)  AS latency_us,
    &amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;level&amp;#39;]                    AS response_level,
    CAST(&amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;status&amp;#39;]  AS SMALLINT) AS status,
    &amp;quot;Events.Attributes&amp;quot;[2][&amp;#39;target&amp;#39;]                   AS response_target
  FROM &amp;#39;otel_traces.parquet&amp;#39;
  WHERE ServiceName = &amp;#39;marending&amp;#39;
) TO &amp;#39;marending_materialized.parquet&amp;#39; (FORMAT PARQUET);&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT uri, COUNT(*) AS error_count
FROM &amp;#39;marending_materialized.parquet&amp;#39;
WHERE status &amp;gt;= 400
GROUP BY uri
ORDER BY error_count DESC
LIMIT 50;&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  DATE_TRUNC(&amp;#39;day&amp;#39;, Timestamp) AS day,
  CASE
    WHEN user_agent LIKE &amp;#39;%Firefox%&amp;#39; THEN &amp;#39;Firefox&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Edg%&amp;#39;     THEN &amp;#39;Edge&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Chrome%&amp;#39;  THEN &amp;#39;Chrome&amp;#39;
    WHEN user_agent LIKE &amp;#39;%Safari%&amp;#39;  THEN &amp;#39;Safari&amp;#39;
    WHEN user_agent ILIKE &amp;#39;%bot%&amp;#39;    THEN &amp;#39;Bot&amp;#39;
    ELSE &amp;#39;Other&amp;#39;
  END AS browser,
  COUNT(*) AS requests,
  AVG(latency_us) AS avg_latency_us
FROM &amp;#39;marending_materialized.parquet&amp;#39;
GROUP BY day, browser
ORDER BY day, requests DESC;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/details&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 1. Duration of GPS queries against different Parquet files&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;p&gt;JSON, Map and materialized columns behave pretty much exactly like I would have expected. Only the
one I actually cared about, Variant, is disappointing. I might be hitting
&lt;a href=&quot;https://github.com/duckdb/duckdb/issues/22024&quot;&gt;this issue&lt;/a&gt;. Although at the time of writing, I get
similar performance on a nightly build of DuckDB even though it’s supposedly fixed on main. I think
this must truly be a limitation that will be addressed at some point. Looking at the query plan with
&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, it’s clear that a lot of variant extractions happen, even though in my
understanding it shouldn’t have to do that.&lt;/p&gt;
&lt;p&gt;In general, I’m impressed with the performance of DuckDB here. There are just over 30’000 rows in
this dataset and it churns through that in no time even with JSON types.&lt;/p&gt;
&lt;p&gt;Also interesting are the file sizes of the different parquet files, one can clearly see how the
faster formats also compress the data better.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JSON: 2.2MB&lt;/li&gt;
&lt;li&gt;Map: 1.3MB&lt;/li&gt;
&lt;li&gt;Variant: 1.1MB&lt;/li&gt;
&lt;li&gt;Materialized: 1.0MB&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the access logs, it looks similar. Although here this DuckDB limitation around Variant hits even
harder. We must be hitting a highly unoptimized code path here to be that much slower than even JSON
handling.&lt;/p&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 2. Duration of access log queries against different Parquet files&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;h2 id=&quot;clickhouse-comparison&quot;&gt;ClickHouse comparison&lt;/h2&gt;
&lt;p&gt;I don’t want to get too deep into this, as the goal here is primarily to establish the feasibility
of querying my data from a Parquet file. But just to get a feel for the landscape of query engines,
I’m going to translate the four queries I used here to ClickHouse equivalents and run them against
the live system. Note that this is very much an apples-to-oranges comparison: The numbers thus far
have been achieved on an M1 MacBook Air, the ClickHouse ones on a Hetzner CAX21. And the Parquet
files have first been isolated to their respective datasets, while ClickHouse has an assortment of
data in its &lt;code&gt;otel_traces&lt;/code&gt; table. Not to mention that the Hetzner server is a noisy shared VPS.&lt;/p&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 3. Duration of queries against ClickHouse&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;p&gt;Still, I’m honestly a bit surprised. I was expecting ClickHouse to do better. Looking back to
&lt;a href=&quot;/notes/clickhouse/&quot;&gt;this comparison&lt;/a&gt; where I looked at rather similar queries, ClickHouse was
pulling way ahead of DuckDB. But there I was checking throughput rather than latency, maybe
parallelism is where the client-server model shines. Either way, this only reinforces that DuckDB
should be up to the task as I was satisfied with the performance of ClickHouse.&lt;/p&gt;
&lt;p&gt;For fairness it should be mentioned that the schema used in ClickHouse is quite a bit more
complicated than our neat Parquet files and uses the &lt;code&gt;Map&lt;/code&gt; type. I would suspect we could go
considerably faster by using ClickHouse’s own JSON type, which behaves similarly to Variant in my
understanding.&lt;/p&gt;
&lt;h2 id=&quot;verdict&quot;&gt;Verdict&lt;/h2&gt;
&lt;p&gt;I think I’m reaching a different conclusion than what I set out to see, but I’m still not
complaining. I was expecting to see subpar performance on JSON strings, with Variant saving the day
and making my undertaking feasible. But I got the opposite: JSON handling is surprisingly fast in
DuckDB and the Variant support is unfortunately too young for real-world use.&lt;/p&gt;
&lt;p&gt;But the bigger take-away is that the general pattern of writing and querying Parquet files could
actually be quite elegant. While I would be very nervous trying to alter the &lt;code&gt;otel_traces&lt;/code&gt; table in
a running system right now, rewriting Parquet files to use Variant down the line should be a walk in
the park (save for rewriting queries of course). In a follow-up note I’ll have to home in on Parquet
file handling, e.g. is there an impact when I have a single file that mixes data sources? And how to
simultaneously write files periodically and query across different files?&lt;/p&gt;
&lt;p&gt;Being terrible at leaving my side-projects be comes with plenty of downsides, but I do enjoy that
the iteration tends to result in leaner and simpler systems.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Update from August 2026:&lt;/p&gt;&lt;p&gt;I just read the &lt;a href=&quot;https://duckdb.org/2026/08/17/duckdb-20-highlights&quot;&gt;DuckDB 2.0 preview blog post&lt;/a&gt;. In
it, they mention that Parquet becomes a first-class citizen, and excitingly, that JSON is going to be
backed by Variant some time after 2.0. So JSON is probably the way to go: at first with pretty good
performance, and then a sudden jump in performance as it becomes backed by Variant.&lt;/p&gt;&lt;p&gt;I was reinvigorated to check whether my benchmark queries would now be faster in the preview version
of DuckDB 2.0. But unfortunately, it was not meant to be. Instead, I had DeepSeek v4 Flash experiment
around to find the issue, and it didn’t dissappoint. Apparently projection pushdown in array types isn’t
implemented yet. Those are, anyway, just an artifact from mapping the OpenTelemetry span attributes (an
array) directly to the Variant type. After a quick cleanup unifying the objects inside the array into one
big object, I was delighted to find performance within a factor of 2 of the “materialized” version.
Great success.&lt;/p&gt;&lt;p&gt;So in my wide-event vision, this problem would not occur anyway.&lt;/p&gt;&lt;/div&gt;&lt;/article&gt;</content:encoded></item><item><title>ePaper air quality dashboard</title><link>https://marending.dev/notes/epaper-dashboard/</link><guid isPermaLink="true">https://marending.dev/notes/epaper-dashboard/</guid><description>Should have just used a kindle</description><pubDate>Mon, 13 Apr 2026 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;14 Apr 2026&lt;/p&gt;&lt;h1&gt;ePaper air quality dashboard&lt;/h1&gt;&lt;h2&gt;Should have just used a kindle&lt;/h2&gt;&lt;p&gt;I’ve been collecting CO2 concentration, humidity, temperature and other properties of our
apartment’s air for some time now. That data only lived in a ClickHouse instance with a Grafana
dashboard to view it, but I’ve been missing a physical display for other people to view (and
appreciate) the data. So I’ve finally set out to build a little ePaper dashboard.&lt;/p&gt;
&lt;p&gt;At first I wanted to use an old Kindle Paperwhite I had lying around from years ago and build a
little frontend application that renders charts using a plotting library. Then I realized that my
Kindle was so old that it couldn’t run JavaScript to the extent required to chart data. Instead, I
found
&lt;a href=&quot;https://www.elecrow.com/crowpanel-esp32-5-79-e-paper-hmi-display-with-272-792-resolution-black-white-color-driven-by-spi-interface.html&quot;&gt;this ePaper device&lt;/a&gt;
by Elecrow for 30 bucks. It includes an ESP32-S3 to control the display. The original plan was to
expose an endpoint from a service connected to the ClickHouse DB that serves the data I want to show
on the dashboard. Then, the ePaper device would consume this data and render charts and values.&lt;/p&gt;
&lt;p&gt;Once I started looking at the library the manufacturer ships to draw to the screen, it didn’t take
long to realize that this wasn’t going to be so easy. Between all the comments in Chinese, it became
clear that rendering an image on the server and merely painting that to the ePaper screen is the
preferable way. If I went this way from the start, I probably could have made the Kindle work after
all, although maybe still requiring jailbreaking.&lt;/p&gt;
&lt;p&gt;Either way, I’ve now built an endpoint in my Rust service that renders an image comprised of a main
CO2 chart as well as a couple of scalar values like current temperature and humidity, as well as -
crucially - the current humidity in our laundry room.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_dashboard.W8ds2dtI_2V5Ar.webp&quot; alt=&quot;Dashboard image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;792&quot; height=&quot;272&quot;&gt;&lt;/p&gt;
&lt;p&gt;The CrowPanel device then loads this image every 2 minutes and paints it to the screen.
Unfortunately I didn’t get deep sleep working without the screen somehow showing weird banding, so
the device currently just sits around idle in between cycles. If you care to see the code on the
device, you can
&lt;a href=&quot;https://github.com/beingflo/embedded-v2/blob/main/dashboard/dashboard.ino&quot;&gt;find it here&lt;/a&gt;. The code
that renders the image can be found
&lt;a href=&quot;https://github.com/beingflo/events/blob/main/service/src/dashboard.rs&quot;&gt;here&lt;/a&gt;. All said and done,
I’ve now set up the device in kind of a hallway in our apartment (in an admittedly janky way; I’ll
think of a more permanent fixture one day, surely).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_device.CaBb22m6_10Iw67.webp&quot; alt=&quot;Device image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1280&quot; height=&quot;960&quot;&gt;&lt;/p&gt;
&lt;p&gt;On a side note, both the rendering code and the embedded code were heavily vibe-coded. Iterating on
the rendering in particular worked very well. On the embedded side, despite having access to the
whole drawing library and a bunch of example code, it took a lot of churning to get usable code. And
even then, the device occasionally just seizes up — probably the error handling with Wi-Fi
connectivity is flaky, or there may even be a memory leak. I’ll need to manually look into this.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>My first firefox extension</title><link>https://marending.dev/notes/extension/</link><guid isPermaLink="true">https://marending.dev/notes/extension/</guid><description>Personalize your software</description><pubDate>Sat, 21 Mar 2026 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;22 Mar 2026&lt;/p&gt;&lt;h1&gt;My first firefox extension&lt;/h1&gt;&lt;h2&gt;Personalize your software&lt;/h2&gt;&lt;p&gt;I wouldn’t say I have an addictive personality, but I do spend too much time on YouTube. It’s a
devious pattern where I watch educational content, learn a bunch of useless stuff and feel like I’m
not wasting my time doing it. I don’t want to stop watching videos on YouTube entirely, there &lt;em&gt;is&lt;/em&gt;
some genuinely fantastic content on there. But I need to limit my time on it.&lt;/p&gt;
&lt;p&gt;I’ve been aware of this pattern for years, but didn’t do anything about it. Then, I installed
&lt;a href=&quot;https://addons.mozilla.org/en-US/firefox/addon/leechblock-ng/&quot;&gt;LeechBlock&lt;/a&gt;, a Firefox extension
that allows you to set up block rules for certain websites. Like say, at most 30 minutes of YouTube
per day and after that it will block further access. Depending on my mood, I would then habitually
overrule the block. So it wasn’t exactly working.&lt;/p&gt;
&lt;p&gt;Then, a couple of weeks ago I had an idea. What if I don’t just allow myself a maximum duration of
YouTube per day that resets every day but instead keep a rolling balance that increases by X minutes
every day and decreases by however much I use of it, effectively allowing a roll over of unused
time? I postulated that this may help psychologically as not spending time on YouTube today is not a
lost opportunity, but rather delayed gratification.&lt;/p&gt;
&lt;p&gt;While LeechBlock has the option to roll over time, it doesn’t quite work the way I need it to. So I
set out to build my own.&lt;/p&gt;
&lt;p&gt;Starting out with a tutorial on MDN, I was surprised how easy it is to get a first demo extension
built and installed. From there, I iterated with Claude to get the behaviour I needed. And boy did
it take some prompting. While Claude understands the APIs available to an extension just fine, it
struggled with concepts like timers not firing when the device is sleeping.&lt;/p&gt;
&lt;p&gt;The result is this &lt;a href=&quot;https://github.com/beingflo/block-extension&quot;&gt;tiny extension&lt;/a&gt;. It keeps track of
the remaining time budget and when it was last updated in local storage. Whenever YouTube is in
focus, it updates the budget and the timestamp every second. When there is a visibility change, it
checks when the budget was last updated and adds time as appropriate. I currently have it afford 2.5
minutes every hour, which comes to 1 hour of YouTube per day. You gotta start somewhere.&lt;/p&gt;
&lt;p&gt;It’s worked well so far. As theorized, I have an easier time doing something else and not feeling
like I’m missing out, as I’m just &lt;em&gt;postponing&lt;/em&gt; watching that video where the guy explains how
dishwashers work. I should build software that fits my needs exactly more often.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Update from the future: Overall, this has worked well. One loophole I started to exploit is that
the hourly refill of 2.5 minutes gives enough time to find an interesting video (which is almost
always longer than 2.5 minutes). Combined with the fact that access is only blocked on page
reload, not mid-video, this is problematic. Instead, I’ve now resorted to allotting 45 minutes at
midnight. This way, the budget is used up for the day, not just for the hour.&lt;/p&gt;&lt;/div&gt;&lt;/article&gt;</content:encoded></item><item><title>Storing SSH and encryption keys in 1Password</title><link>https://marending.dev/notes/keys-in-pm/</link><guid isPermaLink="true">https://marending.dev/notes/keys-in-pm/</guid><description>My laptop is not a one-stop shop for attackers</description><pubDate>Sat, 17 Jan 2026 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;18 Jan 2026&lt;/p&gt;&lt;h1&gt;Storing SSH and encryption keys in 1Password&lt;/h1&gt;&lt;h2&gt;My laptop is not a one-stop shop for attackers&lt;/h2&gt;&lt;p&gt;I like to &lt;a href=&quot;/notes/server-setup/&quot;&gt;keep my hosting setup simple&lt;/a&gt;. That includes not operating a
service for secret management on my server. Instead, I’ve opted to keep secrets in local &lt;code&gt;.env&lt;/code&gt;
files that are transmitted to the server during deployment. I also don’t run continuous integration
pipelines for deployment, but just issue docker commands via a remote context to the server, which
uses SSH for authentication. Naturally, for maximum comfort I don’t have a passphrase on my key.
This simplicity comes at a security cost that I haven’t explicitly thought about before. But after
looking into &lt;a href=&quot;/notes/passkeys&quot;&gt;passkeys&lt;/a&gt; lately, security has been more present in my mind, so I’m
writing this note to reason my way through this whole mess.&lt;/p&gt;
&lt;p&gt;First, my approach of keeping secrets locally comes with a very practical concern: it’s &lt;em&gt;very&lt;/em&gt; easy
to accidentally commit &lt;code&gt;.env&lt;/code&gt; files to the repo, exposing your API keys and such to everybody. This
risk is quite obvious and, dare I say, not something I think would happen to me. Regardless, a
mechanism where it’s so easy to do the catastrophically wrong thing is arguably badly designed
either way. But more importantly, I’ve always considered my laptop, my local files, a sanctuary. A
&lt;em&gt;server&lt;/em&gt; hosting services that are exposed to the internet obviously need to be protected from all
the bad actors out there, but my laptop? How could anyone possibly gain access to it? I could have
easily listed a bunch of ways how that could happen &lt;em&gt;theoretically&lt;/em&gt;, but I wouldn’t seriously have
considered that I would have to take precautions against it. “Times of peace …” and all that.&lt;/p&gt;
&lt;p&gt;A string of supply chain attacks have made me reconsider my stance. Not only do I myself use a bunch
of dependencies on my projects, software I use have a bunch of dependencies. Did you know that an
NPM package can run arbitrary code in lifecycle scripts? I.e. any one of your NPM dependencies can
declare a &lt;code&gt;postinstall&lt;/code&gt; script that is run on your machine after installing the package. Such a
script could easily read your &lt;code&gt;.env&lt;/code&gt; file or even your &lt;code&gt;.ssh/id_rsa&lt;/code&gt; key and send it off to
wherever! Or maybe it could install a little hook on your project that will modify the generated
code subtly even after you’ve removed the dependency. The possibilities are endless. Even if you
have &lt;em&gt;your&lt;/em&gt; dependencies under control, any piece of software that runs on your system could have an
exploit snuck in via one of &lt;em&gt;their&lt;/em&gt; dependencies. Conversely not only does access to my computer
compromise my productive secrets, it also allows an attacker to deploy malicious software on &lt;em&gt;my&lt;/em&gt;
behalf. It’s kind of depressing to think about.&lt;/p&gt;
&lt;p&gt;After the “Shai-Hulud” compromise, of course the entire industry is trying to find solutions to this
issue. In the meantime, I’m primarily concerned with trying to avoid having sensitive files lie
around on my system. I don’t want to give up on the simplicity of my setup though, I still want to
directly deploy software from my laptop to my server, and I also want to avoid secrets management on
my server. So it’s time for one of my signature simple-yet-complicated solutions.&lt;/p&gt;
&lt;p&gt;I use 1Password as my password manager of choice. It’s solid. Only recently did I learn that there
is a &lt;a href=&quot;https://developer.1password.com/docs/cli/&quot;&gt;CLI&lt;/a&gt; as well as an
&lt;a href=&quot;https://developer.1password.com/docs/ssh/agent/&quot;&gt;SSH agent&lt;/a&gt;. I immediately connected the dots with
the conundrum in the back of my head.&lt;/p&gt;
&lt;p&gt;Let’s consider the SSH key I use to log in to my server first. I created a new key in 1Password, set
up the agent, transferred the public key to the server and then deleted the old key. Now, whenever I
want to &lt;code&gt;ssh&lt;/code&gt; into the server, the ssh client asks the agent to perform cryptographic operations
using the private key. At that point, 1Password pops up a dialog that has me unlock the vault (using
password or biometrics). Neat! To my surprise, this also works flawlessly when I run docker commands
towards a remote ssh context. I did the same for my Github SSH key, as that could also be used to
push malicious code to my projects.&lt;/p&gt;
&lt;p&gt;As for my &lt;code&gt;.env&lt;/code&gt; files, I now started using &lt;a href=&quot;https://github.com/getsops/sops&quot;&gt;SOPS&lt;/a&gt; with
&lt;a href=&quot;https://github.com/FiloSottile/age&quot;&gt;age&lt;/a&gt; to encrypt the files and commit them to their respective
repos. Here’s how this works. First, I generate a new &lt;code&gt;age&lt;/code&gt; keypair.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&amp;gt; age-keygen
# created: 2026-01-18T19:10:14+01:00
# public key: age1kasp3na0g9vmp9htuduhn6fdxu2yc7am45lvg48ta97t66nypqys0zvm70
AGE-SECRET-KEY-1KKX7L4J7EXQQJA4K6XCS4SLZANGXEVKAX3H0T2L8Z7QYCUV5T3KQ5LX7TW&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I store public and private keys in 1Password. Then, I create a new &lt;code&gt;.sops.yaml&lt;/code&gt; file in the project.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;# .sops.yaml
creation_rules:
  - path_regex: \.env.*$
    age: &amp;#39;age1kasp3na0g9vmp9htuduhn6fdxu2yc7am45lvg48ta97t66nypqys0zvm70&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, when you run a SOPS encryption in this project, it will take the configured public key from
this file.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&amp;gt; sops -e --input-type dotenv --output-type dotenv .env.prod &amp;gt; .env.prod.enc

# .env.prod.enc
OTEL_SERVICE_NAME=ENC[AES256_GCM,data:RfR0yTaqEbhh,iv:Lkx6qiB46iKye3ZUKPTXzjSv1VctQ0qWfZXYHmxOE1Q=,tag:IMxdFIF4aDX1AxuFEG9ZAg==,type:str]
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=ENC[AES256_GCM,data:vkHPgvhiLrjYAZeVGzPcxy+yMtLcdHQXehaW2XI1GJAIIPMp,iv:5SL6MO9HTKbsGh0U9bR7Xa1MzKP8FDBanaLHkXqAzRg=,tag:Mo3N1qes8ZLpuflkP1a+MA==,type:str]
OTEL_BSP_MAX_QUEUE_SIZE=ENC[AES256_GCM,data:PIETWdtc,iv:3aoQpSb9EDOAb8Qa0hU/LIafxa5t5VnKM6cRjmGcD6o=,tag:oCR8/cIbffN4lP410WpHcg==,type:str]
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=ENC[AES256_GCM,data:xSdryjU=,iv:0xs74ILRRzpbUkWMhx5t3MYzODZYrjwAiXZPg9QbCaI=,tag:KXT93vSRIUxJQW2DkjcEEA==,type:str]
SERVE_PORT=ENC[AES256_GCM,data:I/XPtQ==,iv:+vjGknnpXIHX0HCThT/8SzBiFs7IsYjsl66faf78cGk=,tag:XnK5jT/hTOVijajfl53AUg==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRQ0hObVJ0VS91cktzcHZG\nM0xHZFZLdDVLSUlocVFpck8wM1g2YlBNdVZvClovbWs5UmVpVVo1MDlpZFg3U29i\nNFk0ZFZ2cUMwWUIyS1JpczdVTHlJdG8KLS0tIFNGNzl2V1RqN2s2SG9tbUEwc2or\nNzBncVg4L2s4MHNMUjB3MUU3WHJrVzAKoQUYuztqDD4Z2HJYoUhABXJmJGAGbGIr\nKnfZWZRpjawijjkPdW8WNslrxCQx5j3FFr4Fr1h+V1ueX14aHAvyKg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1tlteuzp4uwd7ld28s2ncstgmh92jja84y4a4frrl0hhwc04vpyzsqpqkyl
sops_lastmodified=2026-01-16T21:08:17Z
sops_mac=ENC[AES256_GCM,data:JjD3VlHY0ih4kItPRH7zmMOT5Qvklx8+xJxmTKKqa8OH6+j5bpM3hFXStcyXZ9qpNM/DNu50Hn6y7QKW3IrrLylF5EYLeFWk9Ys6/VmtbsjWUXuoEmsmHDcERtHo8AwBatzqwny+M1YRs/hJEqmgRQydKxg1c6ducIPv6fmlXfM=,iv:J0i6WWiccFtBbyUPvAU+KC3EmlYg+fIr3xXxjcmg+Rc=,tag:a2RmQ7P3XcuCnHTNOtcz/A==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.11.0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;dotenv&lt;/code&gt; type is so that the resulting file is easier to diff in git. You can try without it and
see the &lt;code&gt;json&lt;/code&gt; format it defaults to (unless the input filename fits a pattern, don’t ask me). As
long as the &lt;code&gt;age&lt;/code&gt; private key is not compromised, this file is now safe to commit to the repo and
the original &lt;code&gt;.env.prod&lt;/code&gt; file is deleted.&lt;/p&gt;
&lt;p&gt;At this point, there is nothing sensitive on disk in plaintext. But how can my deployment script
still see the secrets? This brings us to the 1Password CLI. At the appropriate spot in my script
(again, go read &lt;a href=&quot;/notes/server-setup/&quot;&gt;this&lt;/a&gt; for details) I insert the following lines.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;trap &amp;quot;rm -f .env.prod&amp;quot; EXIT
export SOPS_AGE_KEY=$(op item get &amp;quot;SOPS age key - marending.dev&amp;quot; --reveal --fields &amp;quot;private key&amp;quot;)
sops -d --input-type dotenv --output-type dotenv .env.prod.enc &amp;gt; .env.prod&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;op&lt;/code&gt; command is the 1Password CLI that allows retrieving items from my vault programmatically.
In this case, I’m reading out the “private key” field from the appropriate item. Then, with the key
in an environment variable, I decrypt &lt;code&gt;.env.prod.enc&lt;/code&gt; and store it in a plaintext &lt;code&gt;.env.prod&lt;/code&gt; file.
After this the actual deployment takes place. Finally, the trap we set up in the first line is going
to ensure that the plaintext file is deleted when the script exits, be that cleanly or failing
somewhere. If you’re wondering about the environment variable, rest assured that it’s only visible
to the script and any child processes it spawns, not to unrelated process on the system.&lt;/p&gt;
&lt;p&gt;So for a brief moment during deployment, the plaintext secrets are on disk, but then promptly
deleted again. This is a big improvement over having them always lie around. My attack surface
&lt;em&gt;does&lt;/em&gt; expand to include 1Password, but if that were breached, I, and my software would be toast
anyway with all my passkeys to Github, Hetzner, Porkbun etc. in there. In conclusion, I think this
approach improves my security (and that of my users) significantly, at least in the face of
opportunistic and automated attacks. But now that I think about this, an attacker that has
read-write access to my system &lt;em&gt;could&lt;/em&gt; modify my deployment script to send the private key off
somewhere and I might not notice as I expect the verification prompt from 1Password.&lt;/p&gt;
&lt;p&gt;If I get too pensive about these things, I start to realize all hope is lost anyway and I might as
well go off-grid with some chickens.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Understanding passkeys</title><link>https://marending.dev/notes/passkeys/</link><guid isPermaLink="true">https://marending.dev/notes/passkeys/</guid><description>And improving my online security in the process</description><pubDate>Tue, 13 Jan 2026 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;14 Jan 2026&lt;/p&gt;&lt;h1&gt;Understanding passkeys&lt;/h1&gt;&lt;h2&gt;And improving my online security in the process&lt;/h2&gt;&lt;p&gt;I’ve been meaning to look into passkeys for a while now. I always enjoy being informed on new
developments in the industry, but for some reason this particular technology has been eluding me. A
couple of misconceptions combined with entirely too much misinformation on the internet made
passkeys unapproachable for me. Finally, I want to dive deep, properly understand them, and start
using them. Luckily, I already have two
&lt;a href=&quot;https://www.yubico.com/ch/product/yubikey-5-series/yubikey-5c-nfc/&quot;&gt;Yubikeys&lt;/a&gt; lying about that I
bought years ago for hardware-based OTP but never ended up using. They support the standards in
question, so they will come in handy when playing around with passkeys.&lt;/p&gt;
&lt;h2 id=&quot;what-are-passkeys&quot;&gt;What are passkeys&lt;/h2&gt;
&lt;p&gt;First, it’s important to establish a broad view of passkeys and their underlying standards. In
practical terms, passkeys are cryptographic key pairs that are generated and handled by
&lt;em&gt;authenticators&lt;/em&gt;. These can be hardware-based like security keys or software-based like platform
keychains or password managers. The
&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API&quot;&gt;WebAuthn&lt;/a&gt; web standard
specifies how websites interact with the browser to register new passkeys and authenticate with
existing passkeys. How the browser then talks to authenticators is captured in the Client to
Authenticator Protocol (CTAP). Together, WebAuthn and CTAP make up the broader
&lt;a href=&quot;https://fidoalliance.org/passkeys/&quot;&gt;FIDO2&lt;/a&gt; standard.&lt;/p&gt;
&lt;h2 id=&quot;registration-and-authentication-flows&quot;&gt;Registration and authentication flows&lt;/h2&gt;
&lt;p&gt;Let’s now have a simplified look at how clients, browsers and authenticators interact. First, the
registration flow to establish new credentials:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_register.DmJV_tiR_ZsmxjX.svg&quot; alt=&quot;registration flow&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1061&quot; height=&quot;1170&quot;&gt;&lt;/p&gt;
&lt;p&gt;Following this, existing credentials are used to authenticate:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_authenticate.BGeiMCMV_1tBTW7.svg&quot; alt=&quot;authentication flow&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1006&quot; height=&quot;1098&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;authenticators&quot;&gt;Authenticators&lt;/h2&gt;
&lt;p&gt;Authenticators are worth having a closer look at, as they play a central role in passkey handling.
They are commonly separated into platform and cross-platform (or roaming). Platform authenticators
come built into your device, like Apple Face ID or Windows Hello. Cross-platform authenticators are
portable. For instance, security keys or password managers are cross-platform, as they can be used
from multiple devices. Smartphones can simultaneously be platform authenticators when signing in &lt;em&gt;on
device&lt;/em&gt; or roaming authenticators when used to sign in &lt;em&gt;on another device&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The FIDO2 standard requires authenticators to at least support testing for &lt;em&gt;user presence&lt;/em&gt;. A
security key for instance will require the user to touch a capacitive button before a challenge is
signed. The Apple Face ID authenticator will additionally support &lt;em&gt;user verification&lt;/em&gt; via biometrics
or PIN.&lt;/p&gt;
&lt;p&gt;Notice that how key pairs are stored is not part of the specification. Some authenticators, like
security keys, will generate a key pair and never hand out a private key. This means the key is tied
to the physical possession of the device. Password managers on the other hand will offer to sync
passkeys between your devices. This added comfort comes at the cost of having to trust your password
manager to keep your keys safe in transit.&lt;/p&gt;
&lt;h2 id=&quot;advantages-of-passkeys&quot;&gt;Advantages of passkeys&lt;/h2&gt;
&lt;p&gt;The security industry has learned from the way traditional passwords have been exploited and has
thus built a bunch of improvements into the standards behind passkeys:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;They are generated by the authenticator, not the user. This makes it impossible to accidentally
generate weak passkeys, as is commonly done with passwords. As a result, passkeys are also
automatically unique, as the user has no chance to reuse one.&lt;/li&gt;
&lt;li&gt;The key to prove your identity and the key to check your proof are not the same. This means that
the server does not need to protect a shared secret. If a site is breached, nothing of value about
your ability to authenticate can be stolen. Passwords on the other hand are a shared secret
between user and server, so the server must take great care to handle them responsibly.&lt;/li&gt;
&lt;li&gt;They offer protection against phishing. Passkeys are bound to an origin, that means an attacker
cloning a website cannot coax your authenticator into signing a challenge. Users with passwords on
the other hand readily type them into a fake site that looks real.&lt;/li&gt;
&lt;li&gt;They are easy to use. While most of us use password managers, the process of auto-filling username
and password fields is error-prone, as it relies on parsing the web page for fields with
appropriate attributes. Many sites do this wrong, leading to having to copy and paste passwords.
Passkeys on the other hand build on proper programmatic APIs that can be easily implemented to
specification by website, browser and authenticator. This should make the process seamless and
robust.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;misconceptions&quot;&gt;Misconceptions&lt;/h2&gt;
&lt;p&gt;With the basics out of the way, let’s look at some common misconceptions to clarify our
understanding.&lt;/p&gt;
&lt;h3 id=&quot;if-you-lose-the-device-with-the-passkey-youre-locked-out&quot;&gt;If you lose the device with the passkey, you’re locked out&lt;/h3&gt;
&lt;p&gt;When I first learned about passkeys and wanted to try them, they were often used synonymously with
security keys. So my biggest misunderstanding that prevented me from adopting passkeys was the
belief that if I lose my Yubikey, I’m going to be locked out of my accounts. The common advice at
that time was to get a &lt;em&gt;second&lt;/em&gt; Yubikey and register a passkey on each device for every account.
Then, deposit one security key in a safe spot so that if you lose the one that you have with you,
there is a fallback.&lt;/p&gt;
&lt;p&gt;This misses two crucial pieces of information. First, passkeys simply replace passwords, they don’t
claim much more than that. So what do you do if you misplace your password? You enter a “Forgot my
password” flow on the auth page of a service. Turns out you can do exactly the same if you lost your
passkey. Most services will allow you to register a new passkey this way if you can prove your
identity, usually by accessing your email account. Second, services typically don’t disable other
authentication methods just because you registered a passkey. In the case of Github, for instance,
you have the option to sign in via passkey or via username / password + two factor authentication.&lt;/p&gt;
&lt;p&gt;Naturally, this has security implications. Your shiny new passkey is only as secure as your email
account or your alternative authentication methods. For this reason, some services will allow you to
disable other authentication modes and fallback mechanisms. Then, and only then, does the original
fear of getting locked out materialize. You better keep those passkeys safe in this case.&lt;/p&gt;
&lt;p&gt;Finally, most people are going to be saving their passkeys in synchronizing authenticators, like
platform keychains or password managers. For these, losing a device doesn’t matter much anyway as
long as you have a way to get back into the authenticator.&lt;/p&gt;
&lt;h3 id=&quot;passkeys-dont-replace-2fa&quot;&gt;Passkeys don’t replace 2FA&lt;/h3&gt;
&lt;p&gt;This is quite a contentious take that illustrates how poor the general understanding of security is
around the internet. You’ll read many misguided arguments that don’t hold up to scrutiny. After some
research, it seems a reasonable argument goes as follows: If the authenticator employs &lt;em&gt;user
verification&lt;/em&gt; during authentication, passkeys do indeed provide similar security guarantees as
passwords with a second factor. Access to the authenticator is something &lt;em&gt;you have&lt;/em&gt;, and the
biometrics or password you use to unlock your authenticator are something &lt;em&gt;you are or know&lt;/em&gt;. Thus,
you have two factors. Now, if your authenticator is &lt;em&gt;not&lt;/em&gt; verifying you during authentication, but
only tests for your presence (as the standard requires), you only have one factor: the physical
possession of the authenticator. An example of such an authenticator is a security key that is not
configured with a PIN and hands out signatures at the touch of a button.&lt;/p&gt;
&lt;p&gt;Now, this line of reasoning feels suspect to me. By this logic, my use of strong passwords stored in
a password manager would also qualify as two factors: access to the authenticator and biometrics to
unlock. I suppose the big difference is that for passwords, a service cannot assume that they are
handled correctly—strong, unique, and stored in a password manager—whereas passkeys are guaranteed
to have these properties. Another iffy aspect of the argument is what happens when your password
manager is breached. In that scenario, you’re immediately compromised. A password in a password
manager and an OTP code provided by an authenticator app on your phone would save you from this.&lt;/p&gt;
&lt;p&gt;So counting factors is not all that matters here. Separate password storage and OTP generation give
you two &lt;em&gt;trust anchors&lt;/em&gt;, versus just the one with passkeys. Instead, we have to consider that 2FA
methods were conceived to reduce a user’s attack surface by making passwords no longer sufficient to
authenticate alone. This way, attackers who gain access to passwords via phishing, database
breaches, etc. can’t log in without also tricking you into providing the second factor. From this
perspective, it’s clear why services like Github consider passkeys sufficiently strong to replace
password + 2FA: they reduce this attack surface by binding keys to origins and eliminating shared
secrets.&lt;/p&gt;
&lt;p&gt;Also note that passkeys can be used as the second factor to strengthen passwords.&lt;/p&gt;
&lt;h2 id=&quot;how-do-i-use-passkeys-now&quot;&gt;How do I use passkeys now?&lt;/h2&gt;
&lt;p&gt;Now that a decent understanding of passkeys has been established, what do I do with this
information? First, let me elaborate on where I’m coming from. I was always very lazy when it comes
to 2FA; to this date, I have 2FA enabled only on the services that force me to use it. As someone
who considers himself a well-informed power user, this is a point of shame for me (hence I’m writing
this note finally). However, after the above revelation that properly managed passwords are actually
not &lt;em&gt;that&lt;/em&gt; much worse than passkeys, it would be easy to feel validated and continue to do business
as usual.&lt;/p&gt;
&lt;p&gt;Instead, now that the uncertainty of how this technology works exactly is gone, I can focus on the
ease of use of passkeys. Especially &lt;em&gt;discoverable credentials&lt;/em&gt; (passkeys that contain metadata about
the user) are just magical: You navigate to a login page, get a popup from your authenticator, put
your finger on the fingerprint sensor, click on “Sign in”, and that’s it. No entering usernames, no
scrambling to get your phone for the OTP code. That ease of use, coupled with the improved security,
actually makes it a no-brainer to use passkeys.&lt;/p&gt;
&lt;p&gt;But which authenticator to use? For me, built-in platform authenticators are not an option due to
vendor lock-in. There &lt;em&gt;is&lt;/em&gt; a draft specification now that will allow exchanging credentials between
authenticators, but I’d rather use an authenticator that is portable from the get-go.&lt;/p&gt;
&lt;p&gt;Further, there is also the opposite problem of vendor “lock-out”: Apple may ban your Apple ID just
for &lt;a href=&quot;https://hey.paris/posts/appleid/&quot;&gt;buying a couple of gift cards&lt;/a&gt;, and Google may ban your
entire account for buying Youtube Premium via a VPN in Turkey. This is also the reason I don’t use
Gmail anymore, I would be putting too many of my digital eggs into one basket. An independent
authenticator sounds more reasonable to me.&lt;/p&gt;
&lt;p&gt;So password managers and security keys are still in the race. Password managers boast the following
advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Convenience &amp;amp; usability. They are easy to access via biometrics or password and are available on
all devices.&lt;/li&gt;
&lt;li&gt;Easy recovery. They synchronize passkeys across devices and into the cloud, ensuring they aren’t
lost when a device breaks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Security keys, on the other hand, have one main strength: security. Passkeys on a Yubikey are
hardware-bound. No breach of any online service is going to compromise credentials.&lt;/p&gt;
&lt;h3 id=&quot;threat-model-considerations&quot;&gt;Threat model considerations&lt;/h3&gt;
&lt;p&gt;The more I think about this, the more I realize that (apart from getting phished, of course) I worry
more about getting locked out of accounts than about trusting nobody. I &lt;em&gt;could&lt;/em&gt; only use security
keys and eliminate trust in a password manager, but I believe the chances that I lose two security
keys are higher than 1Password getting compromised.&lt;/p&gt;
&lt;p&gt;Ultimately, I think hardware-based security is not worth the effort for me. The usability of
password managers wins out for my use cases. But security keys can still have their place in my
security theater. Not as exclusive auth methods, but as additional ones.&lt;/p&gt;
&lt;p&gt;For critical services I really don’t want to lose access to, like email, password manager, and
domain registrar, I’m going to register additional passkeys on both of my security keys. This way, I
always have backup access if I lose access to my primary passkey. This could also serve as a nice
way into my accounts for a spouse should something happen to me. Does this increase my overall
attack surface? Probably. An attacker can now choose between hacking my password manager &lt;em&gt;or&lt;/em&gt;
breaking into my place, stealing my Yubikey, and somehow getting their hands on the PIN—whichever is
easier. But again, I consider getting locked out more likely than someone targeting me to this
degree. And if they do, the good old wrench method is anyway the most effective.&lt;/p&gt;
&lt;p&gt;For the accounts that don’t support passkeys, I’ll now enable 2FA with a TOTP authenticator on my
phone everywhere. Why not use the password manager’s TOTP functionality and do away with the phone
altogether? Honestly, I’m not sure. I suppose when I trust the password manager not to be
compromised, there is little benefit in having a second device.&lt;/p&gt;
&lt;h3 id=&quot;passkeys-in-the-wild&quot;&gt;Passkeys in the wild&lt;/h3&gt;
&lt;p&gt;First impressions of using passkeys with a password manager are good. It’s fast and smooth. The
first problem arises when wanting to use a security key: first, one must dismiss the password
manager prompt, then, when the native prompt presents itself with Apple’s keychain front and center,
one must click on a small “Other options” button, and only then is there the option to use security
keys.&lt;/p&gt;
&lt;p&gt;Next, let’s have a look at specific services around the internet.&lt;/p&gt;
&lt;h4 id=&quot;github&quot;&gt;Github&lt;/h4&gt;
&lt;p&gt;When registering secondary passkeys on security keys, I have to set up a PIN. It looks like Github
requires user verification, so just touching the capacitive button on the Yubikey is not sufficient,
unlike with the other services below. The usability when logging in is kind of weird. When I already
entered the PIN before, I can just touch the button on the login page and I’m in. However, when I’ve
newly plugged the key in, touching the button doesn’t do anything; instead, I have to dismiss the
password manager prompt, select security key, and then I’m prompted for the PIN and can log in. And
now that I’ve set PINs on my keys for Github, I have to enter them for other accounts as well. I
suppose most services set user verification to &lt;code&gt;preferred&lt;/code&gt;, which doesn’t require it, but now that
it’s set, I will get prompted.&lt;/p&gt;
&lt;h4 id=&quot;porkbun&quot;&gt;Porkbun&lt;/h4&gt;
&lt;p&gt;Once a passkey is registered, password login doesn’t work anymore. Unfortunately, there is no option
to influence this behavior, and it’s also not mentioned anywhere. Porkbun also offers the option to
force user verification on the passkey.&lt;/p&gt;
&lt;h4 id=&quot;fastmail&quot;&gt;Fastmail&lt;/h4&gt;
&lt;p&gt;Nothing special here, other than the fact that there is no direct way to add an arbitrary passkey.
Instead, there are “on this device” and “on another device” options. Only with the latter does one
get the option to use security keys.&lt;/p&gt;
&lt;h4 id=&quot;hetzner&quot;&gt;Hetzner&lt;/h4&gt;
&lt;p&gt;Doesn’t support WebAuthn, only 2FA using OTP via mobile device or Yubikey.&lt;/p&gt;
&lt;h4 id=&quot;exoscale&quot;&gt;Exoscale&lt;/h4&gt;
&lt;p&gt;This is an S3 object storage provider I use for backups. It doesn’t support passkeys and does
support OTP via a mobile app, though it requires a phone number to serve as a backup second factor
via SMS. Kind of disappointing for developer tooling. It’s widely known at this point how insecure
SMS-based 2FA is. And if it’s required as a backup, well, the whole chain is only as strong as the
weakest link.&lt;/p&gt;
&lt;h4 id=&quot;1password&quot;&gt;1Password&lt;/h4&gt;
&lt;p&gt;Added OTP for password login and passkeys on both security keys as second factors. 1Password doesn’t
seem to offer registering passkeys as the only authentication mechanism, only as second factors.
Weirdly, even if I sign out everywhere, 1Password never asks for any second factors. Apparently,
this is only required on new devices.&lt;/p&gt;
&lt;h4 id=&quot;google&quot;&gt;Google&lt;/h4&gt;
&lt;p&gt;This is the first service where I get a popup from Firefox warning me that the website is requesting
extended information about my security key. Apparently, this exposes device identifiers of the
security key to enable security-critical websites to disallow insecure or compromised keys. A
(surely only accidentally useful for Google) side effect is that your keys could be tracked across
multiple websites this way.&lt;/p&gt;
&lt;h2 id=&quot;closing-thoughts&quot;&gt;Closing thoughts&lt;/h2&gt;
&lt;p&gt;I’m glad I finally looked into passkeys. Not only are they convenient as a user, but I’m inclined to
also look into WebAuthn as the sole authentication method for future side projects. To this end,
I’ll probably write up a proof-of-concept implementation in a future note.&lt;/p&gt;
&lt;p&gt;This was also helpful to finally get my security hygiene in order. I’d say I’m now in a fairly good
spot: every account of value has at least 2FA enabled. While I’m at it, I’ll also look into putting
my SSH keys into 1Password or a security key to avoid having them lying around on my system.&lt;/p&gt;
&lt;p&gt;As the resident tech person in my circle, I’ll recommend people start adopting passkeys using a
password manager going forward.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Visual regression tests for my website</title><link>https://marending.dev/notes/visual-testing/</link><guid isPermaLink="true">https://marending.dev/notes/visual-testing/</guid><description>Gaining confidence in refactorings</description><pubDate>Mon, 05 Jan 2026 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;06 Jan 2026&lt;/p&gt;&lt;h1&gt;Visual regression tests for my website&lt;/h1&gt;&lt;h2&gt;Gaining confidence in refactorings&lt;/h2&gt;&lt;p&gt;This website is built using &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt; to generate static pages. I author the
notes themselves with &lt;a href=&quot;https://mdxjs.com/&quot;&gt;mdx&lt;/a&gt;, a nice extension to markdown to include inline html
and other components. The static html after building this site is then styled using some rather
convoluted CSS. All this is to say that if I change, for example, a margin of a list item only if it
precedes an image element, this may have unintended consequences on an older note I don’t look at
often.&lt;/p&gt;
&lt;p&gt;Whenever I do such changes, I find myself sampling older notes to see if something is broken.
Lately, I’ve had the idea to use &lt;a href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; to do visual regression
testing. For the uninitiated, this type of testing simply takes automated screenshots of pages
(using a headless browser typically) and compares them against an earlier, considered golden,
snapshot. Should the image deviate by more than some configurable threshold, the test is considered
a failure. Then you either fix your application or in case of a legitimate change, you simply update
the golden snapshot for that particular test.&lt;/p&gt;
&lt;p&gt;These tests would come with the obvious upside of increasing confidence that changes don’t have
unintended side-effects. Especially for a static website where the visual appearance is really all
there is to it. But further, because I check the screenshots into the git repo, I get an automatic
history of what the site looks like at the time of the commit.&lt;/p&gt;
&lt;h2 id=&quot;technical-implementation&quot;&gt;Technical implementation&lt;/h2&gt;
&lt;p&gt;Playwright makes this quite easy out of the box. After initializing a new test project using
&lt;code&gt;npm init playwright@latest&lt;/code&gt; in the same repo as the website itself, I add this single test file:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;import { test, expect } from &amp;#39;@playwright/test&amp;#39;;

const notes = [
  &amp;#39;/&amp;#39;,
  &amp;#39;/projects/&amp;#39;,
  &amp;#39;/about/&amp;#39;,
  &amp;#39;/notes/launchd/&amp;#39;,
  &amp;#39;/notes/jour/&amp;#39;,
  &amp;#39;/notes/reflective/&amp;#39;,
  &amp;#39;/notes/monitoring/&amp;#39;,
  &amp;#39;/notes/otel/&amp;#39;,
  &amp;#39;/notes/llm/&amp;#39;,
  &amp;#39;/notes/clickhouse/&amp;#39;,
  &amp;#39;/notes/server-setup/&amp;#39;,
  &amp;#39;/notes/jpeg-raw/&amp;#39;,
  &amp;#39;/notes/go-rest-quest/&amp;#39;,
  &amp;#39;/notes/responsive-plots/&amp;#39;,
  &amp;#39;/notes/co2-loft/&amp;#39;,
  &amp;#39;/notes/sqlite-vs-duckdb/&amp;#39;,
  &amp;#39;/notes/unstructured-data/&amp;#39;,
  &amp;#39;/notes/rest-quest/&amp;#39;,
  &amp;#39;/notes/fieldnotes/&amp;#39;,
  &amp;#39;/notes/rust-spa/&amp;#39;,
  &amp;#39;/notes/16-hour-projects/&amp;#39;,
  &amp;#39;/notes/wasm-benchmark/&amp;#39;,
  &amp;#39;/notes/vps-benchmarks/&amp;#39;,
  &amp;#39;/notes/sqlite-benchmarks/&amp;#39;,
  &amp;#39;/notes/league-rating/&amp;#39;,
  &amp;#39;/notes/league-data/&amp;#39;,
  &amp;#39;/notes/co2-bedroom/&amp;#39;,
  &amp;#39;/notes/esp-protocol/&amp;#39;,
  &amp;#39;/notes/esp-power/&amp;#39;,
  &amp;#39;/notes/performance/&amp;#39;,
  &amp;#39;/notes/website/&amp;#39;,
  &amp;#39;/feedback/&amp;#39;,
];

test.describe(&amp;#39;Visual regression&amp;#39;, () =&amp;gt; {
  const baseUrl = &amp;#39;https://marending.dev&amp;#39;;

  for (const note of notes) {
    test(`capture page: ${note}`, async ({ page }) =&amp;gt; {
      const url = `${baseUrl}${note}`;

      await page.goto(url);
      await page.waitForTimeout(200);

      const pageHeight = await page.evaluate(() =&amp;gt; document.body.scrollHeight);

      for (let scrolled = 0; scrolled &amp;lt; pageHeight; scrolled += 200) {
        await page.mouse.wheel(0, 200);
        await page.waitForTimeout(200);
      }

      await page.waitForLoadState(&amp;#39;networkidle&amp;#39;);

      const screenshotName =
        note
          .replace(/^\//, &amp;#39;&amp;#39;)
          .replace(/\/$/, &amp;#39;&amp;#39;)
          .replace(/[^a-z0-9]/gi, &amp;#39;-&amp;#39;)
          .toLowerCase() || &amp;#39;index&amp;#39;;

      await expect(page).toHaveScreenshot(`${screenshotName}.png`, {
        fullPage: true,
      });
    });
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are a couple of things to note here. The magic happens on the
&lt;code&gt;await expect(page).toHaveScreenshot&lt;/code&gt; line. This makes Playwright take a screenshot and compare it
against a stored screenshot. If no screenshot with this name exists, it will fail the test and you
have to first generate a screenshot by running your suite with &lt;code&gt;--update-snapshots&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Second, there is some complication involved with taking full page screenshots. My website lazy-loads
images, which means some images aren’t loaded when the page is sufficiently long. I wouldn’t care so
much about that if it wasn’t flaky &lt;em&gt;whether&lt;/em&gt; some images are loaded or not. I noticed that sometimes
particular images were loaded and sometimes not, which defeats the purpose when trying to look for
pixel differences between snapshots. For this purpose, you’ll notice the whole scrolling logic in
the test: I scroll down the whole page 200 pixels at a time to ensure all images are loaded in.&lt;/p&gt;
&lt;p&gt;Lastly, the list of pages I want to test are statically listed in the &lt;code&gt;notes&lt;/code&gt; variable. At first, I
actually generated this dynamically by programmatically visiting the index page and then extracting
all linked targets. In the current design of the site, this yields exactly all subpages. Another way
would be to expose an “endpoint” in the site that produces all the pages in the &lt;code&gt;notes&lt;/code&gt; collection.
Both approaches have the benefit of not requiring me to update the list manually when I publish a
new note, but come with the downside that I need to execute all tests in a single Playwright test.&lt;/p&gt;
&lt;p&gt;You see, this test-inside-for-loop you can see above only works as expected when the array to
iterate over is statically known. In the dynamic approaches I can’t do that. And then you have to
deal with the test failing once the first screenshot doesn’t match, instead of getting a nice
summary in the case where each page is its own clean test.&lt;/p&gt;
&lt;h2 id=&quot;workflow&quot;&gt;Workflow&lt;/h2&gt;
&lt;p&gt;So how do I use this? It would be easy to over-engineer it and run this in CI periodically or build
it into my deployment script. Instead, I decided to keep it simple and stupid. I have this setup
with the images checked into the same repo as the website itself and I run the tests whenever I feel
like I’ve made changes that could affect some other part of the site. There is no point in burning
energy by running them on every commit or constantly failing my deployment just to confirm that
changing a typo on a page does in fact cause visual changes.&lt;/p&gt;
&lt;p&gt;With such simplicity in mind, it’s easy to add real value to my workflow with maybe 2 hours of
effort. I need to do more things like it.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Using launchd on macOS to run periodic tasks</title><link>https://marending.dev/notes/launchd/</link><guid isPermaLink="true">https://marending.dev/notes/launchd/</guid><description>Surveilling myself</description><pubDate>Thu, 01 Jan 2026 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;02 Jan 2026&lt;/p&gt;&lt;h1&gt;Using launchd on macOS to run periodic tasks&lt;/h1&gt;&lt;h2&gt;Surveilling myself&lt;/h2&gt;&lt;p&gt;Some months ago I had the idea to record some data about my computer use for later analysis. I’m
recording the window title of the focused application every 2 minutes and I take screenshots of my
display (main as well as secondary if connected) every 10 minutes. Once this has been running for a
while longer I can analyze how I spend my time on my computer.&lt;/p&gt;
&lt;p&gt;In this note I just want to briefly go into how to achieve this easily on macOS natively using
launchd. It’s quite easy to do but the documentation is lacking, hopefully this write-up clears
things up.&lt;/p&gt;
&lt;p&gt;First, I have a bash script I want to execute periodically. Below is my script for recording the
application and window title in focus. Don’t ask me why AppleScript (the thing passed to
&lt;code&gt;osascript&lt;/code&gt;) is so weird. If you execute this script, it will append the desired data to a file.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/env bash
application=$(osascript -e &amp;#39;tell application &amp;quot;System Events&amp;quot; to tell (first process whose frontmost is true) to return {name}&amp;#39;)
title=$(osascript -e &amp;#39;tell application &amp;quot;System Events&amp;quot; to tell (first process whose frontmost is true) to return {name of window 1}&amp;#39;)
date=$(date +&amp;quot;%Y-%m-%dT%H:%M:%S&amp;quot;)

if [ -n &amp;quot;$title&amp;quot; ]; then
  echo &amp;quot;$date,$application,$title&amp;quot; &amp;gt;&amp;gt; /Users/florian/Projects/aware/window-focus/windows.csv
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The other script I have running is the one to take screenshots. The two code blocks are there to
capture two displays (if connected). Within each block you can see the weird &lt;code&gt;mean&lt;/code&gt; conditional,
this is there to filter out black screens. It seems like the way we’ll schedule these scripts
sometimes runs them while the device is actually sleeping and thus has the screen off. The &lt;code&gt;magick&lt;/code&gt;
invocation compresses the screenshot for storage.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/env bash
date=$(date +&amp;quot;%Y-%m-%dT%H:%M:%S&amp;quot;)
filename=&amp;quot;screenshot_$date&amp;quot;

# Screenshot of primary display
if screencapture &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot.png&amp;quot;; then
  mean=$(/opt/homebrew/bin/magick identify -format &amp;quot;%[fx:mean]&amp;quot; &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot.png&amp;quot;)
  if (( $(echo &amp;quot;$mean &amp;gt; 0.26&amp;quot; | bc -l) )); then
    /opt/homebrew/bin/magick -quality 80% &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot.png&amp;quot; &amp;quot;/Users/florian/Projects/aware/screenshots/data/$filename.jpg&amp;quot;
  fi
  rm &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot.png&amp;quot;
fi

# Screenshot of secondary display
if screencapture -D 2 &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot-2.png&amp;quot;; then
  mean=$(/opt/homebrew/bin/magick identify -format &amp;quot;%[fx:mean]&amp;quot; &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot-2.png&amp;quot;)
  if (( $(echo &amp;quot;$mean &amp;gt; 0.26&amp;quot; | bc -l) )); then
    /opt/homebrew/bin/magick -quality 80% &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot-2.png&amp;quot; &amp;quot;/Users/florian/Projects/aware/screenshots/data/$filename-2.jpg&amp;quot;
  fi
  rm &amp;quot;/Users/florian/Projects/aware/screenshots/data/screenshot-2.png&amp;quot;
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, we need to describe our schedule in an xml file. Here’s the one for the window title
recording. The script is called &lt;code&gt;windows.sh&lt;/code&gt;. Make sure to use absolute paths everywhere, including
the shell scripts as the whole thing will run under a different user.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&amp;quot;1.0&amp;quot; encoding=&amp;quot;UTF-8&amp;quot;?&amp;gt;
&amp;lt;!DOCTYPE plist PUBLIC &amp;quot;-//Apple//DTD PLIST 1.0//EN&amp;quot; &amp;quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&amp;quot;&amp;gt;
&amp;lt;plist version=&amp;quot;1.0&amp;quot;&amp;gt;
&amp;lt;dict&amp;gt;
	&amp;lt;key&amp;gt;Label&amp;lt;/key&amp;gt;
	&amp;lt;string&amp;gt;local.aware.window-focus&amp;lt;/string&amp;gt;
	&amp;lt;key&amp;gt;Program&amp;lt;/key&amp;gt;
	&amp;lt;string&amp;gt;/Users/florian/Projects/aware/window-focus/windows.sh&amp;lt;/string&amp;gt;
  &amp;lt;key&amp;gt;StartInterval&amp;lt;/key&amp;gt;
  &amp;lt;integer&amp;gt;120&amp;lt;/integer&amp;gt;
	&amp;lt;key&amp;gt;StandardOutPath&amp;lt;/key&amp;gt;
	&amp;lt;string&amp;gt;/Users/florian/Projects/aware/window-focus/log.stdout&amp;lt;/string&amp;gt;
	&amp;lt;key&amp;gt;StandardInPath&amp;lt;/key&amp;gt;
	&amp;lt;string&amp;gt;/Users/florian/Projects/aware/window-focus/log.stdin&amp;lt;/string&amp;gt;
  &amp;lt;key&amp;gt;StandardErrorPath&amp;lt;/key&amp;gt;
	&amp;lt;string&amp;gt;/Users/florian/Projects/aware/window-focus/log.stderr&amp;lt;/string&amp;gt;
&amp;lt;/dict&amp;gt;
&amp;lt;/plist&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, to install the job I run the following commands in the terminal. If you’re doing it for the
first time you can probably omit the first line. From what I can tell there is no easy way to
determine if a job was actually installed correctly. I just set the interval low at first and see if
it works, then increase it later. Also, in the &lt;code&gt;log.stderr&lt;/code&gt; file you can see if your script didn’t
execute successfully.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;launchctl unload local.aware.window.plist
cp local.aware.window.plist ~/Library/LaunchAgents/local.aware.window.plist
launchctl load -w ~/Library/LaunchAgents/local.aware.window.plist&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A last bit of advice: If you want to do stuff like take screenshots, this requires accessibility
permissions on macOS. You have to grant those permissions in the settings to the &lt;code&gt;/usr/bin/env&lt;/code&gt;
process.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>jour: An opinionated lab journal</title><link>https://marending.dev/notes/jour/</link><guid isPermaLink="true">https://marending.dev/notes/jour/</guid><description>A project report</description><pubDate>Wed, 31 Dec 2025 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;01 Jan 2026&lt;/p&gt;&lt;h1&gt;jour: An opinionated lab journal&lt;/h1&gt;&lt;h2&gt;A project report&lt;/h2&gt;&lt;p&gt;Years ago I stumbled across a
&lt;a href=&quot;https://sambleckley.com/writing/lab-notebooks.html&quot;&gt;blog post on lab notebooks&lt;/a&gt;. The author argues
that practitioners in many fields keep a journal where they detail what they’re up to, what’s
working and what’s not etc. in their daily work. For some fields such a journal even has legal
implications when patents were disputed. In software engineering however, this is not widespread. At
the time I read the post I knew of no one who kept such notes.&lt;/p&gt;
&lt;p&gt;Sure, we have documentation (maybe), but that’s not the same thing. Documentation is there to
explain how a system works, how to interact with it, maybe what constraints it operates under. But
documentation typically doesn’t explain &lt;em&gt;how&lt;/em&gt; and &lt;em&gt;why&lt;/em&gt; we arrived at this particular solution. Did
we try to solve the problem in a different way first but failed, leading to the current approach?
You won’t find that in the documentation. Old attempts either weren’t committed to the code base or
were squashed away.&lt;/p&gt;
&lt;p&gt;This blog post opened my eyes to an issue I had at work that I couldn’t even put my finger on
before. In my current project at work, I often run into situations where we’re reevaluating a design
decision from two years ago because someone thought of a better way to do it. Inevitably, no one
remembers &lt;em&gt;why&lt;/em&gt; it was built the way it is. But I would often have this nagging feeling that we
considered the proposed solution back then but decided against it for &lt;em&gt;reasons&lt;/em&gt;. Now the big
question (possibly worth weeks of effort) is whether we just didn’t think of it back then or whether
we’re missing the reason now for why we didn’t do it. Or, some surrounding factor changed in the
meantime, now enabling the better approach. If I had kept notes on my attempts, failures,
assumptions etc. I could simply read up on this now and we would have clarity. Alas, those notes
don’t exist.&lt;/p&gt;
&lt;p&gt;Naturally, I resolved to start a lab journal. But instead of using my usual
&lt;a href=&quot;/notes/fieldnotes&quot;&gt;notes app&lt;/a&gt;, I had the feeling I needed something more specialized for the
occasion. An application that would keep entries in a chronological feed, rather than siloed notes.
Enter &lt;a href=&quot;https://github.com/beingflo/journal&quot;&gt;jour&lt;/a&gt;, a simple web app for keeping journal entries with
no hassle. It’s local-first with optional S3 sync. A hosted version is available at
&lt;a href=&quot;https://jour.rest.quest/&quot;&gt;jour.rest.quest&lt;/a&gt;. As you can tell by the URL, this application is another
instance of my local-first series kicked off by &lt;a href=&quot;/notes/rest-quest&quot;&gt;rest.quest&lt;/a&gt;. I would recommend
reading that project report first for the technical details, here I will only go into the feature
set and learnings from this project.&lt;/p&gt;
&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;
&lt;p&gt;If you’ve seen the project reports for &lt;a href=&quot;/notes/rest-quest&quot;&gt;rest.quest&lt;/a&gt; or &lt;a href=&quot;/notes/go-rest-quest&quot;&gt;go&lt;/a&gt;
you know the drill by now: Brutalist, minimalist UIs are my jam. Below is the default view of jour,
a complete feed of every entry I’ve written in reverse chronological order. Each entry has a text
body, some tags and a date. The tags are parsed out of the content when submitting an entry simply
by looking at words starting with a &lt;code&gt;#&lt;/code&gt; in the last line. Tags do not get special treatment other
than being displayed in a lighter tone below the content.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_view.77skMI_d_Z1QBrvk.webp&quot; alt=&quot;Screenshot of jour&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2880&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;On the top, there is a search bar that searches through all entries and filters down the feed
accordingly. I started using this application a while ago and was quite satisfied with it. The one
gripe I had was that when I was writing entries at work, I wanted to automatically have the entries
tagged by a special tag (&lt;code&gt;nca&lt;/code&gt; in my case). Instead of always having to type out this tag manually,
I later added the things you see on the left. When clicking on &lt;code&gt;nca&lt;/code&gt;, there is an implicit filter
for this term that doesn’t show up in the search bar, and every new entry automatically gets tagged
correspondingly. These “autofilters” can be created via shortcuts and deleted via a button that is
revealed on hover.&lt;/p&gt;
&lt;p&gt;Next, by hitting &lt;code&gt;n&lt;/code&gt;, a new entry is created at the top with a textbox and a date picker. By default
the datepicker shows the current date and time, but I quickly found that sometimes one wants to
create entries in the past, so I added the ability to set the date. With &lt;code&gt;cmd+Enter&lt;/code&gt; the entry is
saved.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_write.DgBl2Ht2_1CUum9.webp&quot; alt=&quot;Screenshot of jour&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2880&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;That’s pretty much the functionality that I use every day. One additional bit of eye-candy I added
later is a statistics screen that can be toggled. It shows a GitHub-style heatmap of all entries
laid out over time.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_stats.BROfAHs5_3fBTH.webp&quot; alt=&quot;Screenshot of jour&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2880&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;Here you can see that I’m quite consistent in using jour. The week(s)-long gaps are almost always
vacations.&lt;/p&gt;
&lt;p&gt;At the time of writing there are 848 entries saved. Many of them quite short, some are longer. There
is no progressive loading of entries, everything is shown on one page. Turns out you can go quite
far with such a simple UI when you build it to your specifications. If the sheer number of entries
grows to a point where this becomes laggy, I’ll reconsider.&lt;/p&gt;
&lt;h2 id=&quot;learnings&quot;&gt;Learnings&lt;/h2&gt;
&lt;p&gt;Technologically, jour is nothing to write home about. It’s a slight variation of my
rest.quest-winning-formula. That’s also the reason I could build this out in a mere couple of hours.
The interesting learning for me in this project is more in the direction of disjoint,
use-case-specific applications vs integrated multi-purpose apps. At first, I was very happy with my
new-found trifecta of productivity applications: jour as a lab journal,
&lt;a href=&quot;/notes/rest-quest&quot;&gt;rest.quest&lt;/a&gt; as a todo list and &lt;a href=&quot;/notes/fieldnotes&quot;&gt;fieldnotes&lt;/a&gt; as the notes app.
Over time, I started noticing an indecisiveness about where to record some things. For instance,
after figuring out a shell one-liner to fix an issue I was experiencing, I wouldn’t know whether to
jot this down in the lab journal or as a new note in the notepad. Or taking notes on what’s being
discussed in a sync meeting to refer to later. Dedicated note or quick entry in jour?&lt;/p&gt;
&lt;p&gt;This conundrum has kicked off thoughts I can’t fully verbalize yet. In classical &lt;em&gt;me&lt;/em&gt; fashion, one
option in the room is to build an integrated application that allows bringing all this data
together. But then again, moving away from single-purpose, focused applications to bloated monoliths
doing too many things badly is not exactly a mistake I want to repeat. We’ll see where this goes.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Reflective: A simple self-hosted image gallery</title><link>https://marending.dev/notes/reflective/</link><guid isPermaLink="true">https://marending.dev/notes/reflective/</guid><description>A project report</description><pubDate>Mon, 29 Dec 2025 23:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;30 Dec 2025&lt;/p&gt;&lt;h1&gt;Reflective: A simple self-hosted image gallery&lt;/h1&gt;&lt;h2&gt;A project report&lt;/h2&gt;&lt;p&gt;Let me take you back to around 6 years ago. I was starting to
&lt;a href=&quot;https://en.wikipedia.org/wiki/DeGoogle&quot;&gt;de-Google&lt;/a&gt; my life, replacing Google Drive and Google
Photos with &lt;a href=&quot;https://www.pcloud.com/&quot;&gt;pCloud&lt;/a&gt;. Now, while pCloud works fine as a cloud storage
provider, it completely fails at the photo experience. At that time, viewing images was painfully
slow, thumbnails took 10 seconds to load. It felt like they were regenerating thumbnails on every
view.&lt;/p&gt;
&lt;p&gt;This made me wonder if hosting and efficiently serving images was really that hard, or if pCloud
simply didn’t care about the user experience. Google was pulling it off, but maybe that required
hundreds of engineers and specialized compression schemes.&lt;/p&gt;
&lt;p&gt;So I started my first real side project (apart from some smaller CLI tools) with
&lt;em&gt;&lt;a href=&quot;https://github.com/beingflo/foti/tree/master&quot;&gt;foti&lt;/a&gt;&lt;/em&gt;. This is code I wrote with around 1 month of
on-the-job frontend experience. View it at your own risk. I built a Python server that eagerly
downsampled images for thumbnails and served them via a WebSocket connection (for whatever reason).
The whole thing ran on a Raspberry Pi 4 in a box in my cupboard.&lt;/p&gt;
&lt;p&gt;While the result wasn’t pretty, it validated my suspicion: Serving images at reasonable speeds
&lt;em&gt;wasn’t that hard&lt;/em&gt;. With a proof-of-concept done and my appetite for side projects awakened, I
decided to first hone my web dev skills on an “easier” target: note taking. I thought I’d quickly
build a note-taking application to satisfy a personal need, learn a ton in the process, then swiftly
return to my actual goal — the image gallery.&lt;/p&gt;
&lt;p&gt;If you’ve read about &lt;a href=&quot;/notes/fieldnotes&quot;&gt;fieldnotes&lt;/a&gt;, you’ll know this became much more than a short
intermezzo. I lost myself there for a couple of years, then a bunch of other &lt;a href=&quot;/projects&quot;&gt;projects&lt;/a&gt;
snuck in, and here we are, quite a while later. When I finally returned to it, I started sketching
out the feature set and architecture, only to be distracted again by another project. The second
time around, I drastically cut the scope and finally pushed it over the line. This is the version
I’m writing about now. As always, there are plenty of learnings here. Reflecting on those is the
main reason I write these project reports.&lt;/p&gt;
&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/beingflo/reflective&quot;&gt;Reflective&lt;/a&gt; is currently a simple gallery for
distraction-free viewing of my images. When you open the web app, you’re greeted with a grid of all
the images ingested into the system:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_view.2UFrsEDO_Z1sKhXw.webp&quot; alt=&quot;Screenshot of reflective&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2093&quot; height=&quot;1330&quot;&gt;&lt;/p&gt;
&lt;p&gt;No buttons or labels to distract from the images. When scrolling, the application seamlessly loads
more images. All images are cropped to a 4:3 landscape aspect ratio.&lt;/p&gt;
&lt;p&gt;There’s no categorization into albums or folders, just a simple feed of images in reverse
chronological order.&lt;/p&gt;
&lt;p&gt;Images can have any number of tags associated with them. To find specific images, you can open the
search bar via shortcut (&lt;code&gt;cmd+k&lt;/code&gt;) and search for any tag. In the following screenshot, the images
are filtered to ones tagged &lt;code&gt;japan&lt;/code&gt;. There are 1860 images matching that query.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_search.BcBSqwu0_Z11TKGG.webp&quot; alt=&quot;Screenshot of reflective&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2093&quot; height=&quot;1330&quot;&gt;&lt;/p&gt;
&lt;p&gt;To tag images, open the tag view (&lt;code&gt;cmd+e&lt;/code&gt;) and select individual images, or select all images
between two selections by holding down &lt;code&gt;Shift&lt;/code&gt;. The tag bar shows tags that all selected images
share, tags that only some images have (in lighter gray), and an input field to add new tags to all
selected images.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_tags.CsWhgzn3_ZGlCcH.webp&quot; alt=&quot;Screenshot of reflective&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2093&quot; height=&quot;1330&quot;&gt;&lt;/p&gt;
&lt;p&gt;Back in normal view mode, clicking an image opens it in fullscreen. A lightbox fills the screen with
shortcuts to navigate between images and load the full-resolution version for zooming into details.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_lightbox.D4DwWpGQ_25Abyv.webp&quot; alt=&quot;Screenshot of reflective&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2093&quot; height=&quot;1330&quot;&gt;&lt;/p&gt;
&lt;p&gt;Hitting &lt;code&gt;cmd+i&lt;/code&gt; displays selected metadata below the image.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_metadata.CyLlRSvq_Z1sKLK7.webp&quot; alt=&quot;Screenshot of reflective&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2093&quot; height=&quot;1330&quot;&gt;&lt;/p&gt;
&lt;p&gt;To ingest images into reflective, I copy files into a folder on my server and create a marker file
in a specific location. The server recognizes the new files and starts downsampling them for
thumbnails. Once complete, the new images appear in the view.&lt;/p&gt;
&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;
&lt;p&gt;As with &lt;a href=&quot;/notes/rest-quest&quot;&gt;rest.quest&lt;/a&gt;, I first brainstormed on paper what the application should
do and how it should work. For reflective, this became quite a ramble, but I enjoy this process. In
hindsight, I once again over-engineered everything — it’s always easy to dream up mechanisms and
features, but pragmatism only kicks in when it’s time to implement.&lt;/p&gt;
&lt;p&gt;In the scans below, you’ll find my entire thought process detailed.&lt;/p&gt;
&lt;details&gt;&lt;summary&gt;Full concept&lt;/summary&gt;&lt;div&gt;&lt;p&gt;See if you can find the angry cow in prison.&lt;/p&gt;&lt;div&gt;&lt;p&gt;&lt;img src=&quot;/_astro/_reflective-page1.CERKOuyk_CrMyn.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4396&quot; height=&quot;6570&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page2.CixncQRT_vgLb1.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4375&quot; height=&quot;6253&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page3.oUI3nngc_xLRdP.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4652&quot; height=&quot;6736&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page4.D785BAkf_QvRPL.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4666&quot; height=&quot;6724&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page5.Czi1BUa3_29YK8m.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4730&quot; height=&quot;6607&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page6.BrQ9vGJP_Z3LIKr.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4762&quot; height=&quot;6730&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page7.olCEuf-B_1yQpXn.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4906&quot; height=&quot;6946&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page8.D9_8R8I-_Z1kX1XQ.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4787&quot; height=&quot;6859&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page9.BxRnvVNC_Z28pAGx.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4851&quot; height=&quot;6946&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page10.qaayO54D_Z1LI0yn.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4880&quot; height=&quot;6902&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page11.CWVHY4sN_cc8dr.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4725&quot; height=&quot;6819&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page12.D7t1UKV9_w9xgG.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4789&quot; height=&quot;6858&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page13.DW6ZB0ED_3JLdm.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4640&quot; height=&quot;6641&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page14.Dei5dViO_ZJhfT2.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4757&quot; height=&quot;6774&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page15.B49MqSC4_1mSv7G.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4581&quot; height=&quot;6470&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page16.Bc1tFja8_Z1o53Gz.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4725&quot; height=&quot;6684&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page17.DonFZ6II_ZP6zKd.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4583&quot; height=&quot;6432&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page18.SCpPZerC_ZNWIJH.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4666&quot; height=&quot;6601&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page19.BhvKV5BF_Zk68P1.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4559&quot; height=&quot;6064&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page20.Ca7EYngj_2psOx0.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4500&quot; height=&quot;6251&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page21.BDa0wzdz_xWdWV.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4384&quot; height=&quot;5808&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page22.CqoIDGIa_Z169wKv.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4259&quot; height=&quot;6266&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page23.BffXN2SH_ZhDzrY.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4510&quot; height=&quot;5862&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page24.7rwF5nIy_FeEuI.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4268&quot; height=&quot;6066&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page25.DwYM98Gn_xT5Le.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4521&quot; height=&quot;5960&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page26.Bawco3mD_1hQ8dT.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4346&quot; height=&quot;6470&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page27.JRWJN2Bo_Uhapj.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4941&quot; height=&quot;6631&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page28.Dj1oG-Jv_Zratca.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4609&quot; height=&quot;6565&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page29.hTsXhacp_ZrYn2E.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4709&quot; height=&quot;6238&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page30.BPV3bwFU_Z1gpqi.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4638&quot; height=&quot;6549&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page31.Cr4nMmTY_jgnK0.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4826&quot; height=&quot;6450&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page32.BMhVqtw-_252sBR.webp&quot; alt=&quot;Screenshot of UX
concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4744&quot; height=&quot;6638&quot;&gt; &lt;img src=&quot;/_astro/_reflective-page33.Cq1CsNO4_Z2rOYuT.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4642&quot; height=&quot;6222&quot;&gt;
&lt;img src=&quot;/_astro/_reflective-page34.CcEHPrHg_ZQyXH5.webp&quot; alt=&quot;Screenshot of UX concept&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4673&quot; height=&quot;6414&quot;&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/details&gt;
&lt;p&gt;At first, I was convinced I should store image data in S3. After all, storing images on my server
wasn’t scalable. Architecturally, collocating that much data with the application server is a bad
idea. But storing the data and metadata in separate places creates a synchronization problem. You’ll
find plenty of musing about this issue in my notes.&lt;/p&gt;
&lt;p&gt;Once again, I expertly ignored the fact that I’m the only user and could just skip this whole
problem. After implementing the entire system with upload functionality, presigned S3 URLs, and all
that complexity, I unceremoniously ripped it out. Instead, I now store images on the server, have
the server asynchronously process them for thumbnails, and serve the images directly.&lt;/p&gt;
&lt;p&gt;The architecture has been simplified to the point where there’s nothing left to discuss.&lt;/p&gt;
&lt;h2 id=&quot;learnings&quot;&gt;Learnings&lt;/h2&gt;
&lt;p&gt;Reflective continues my eternal battle against over-engineering. By now, I have the concept of MVP
scope down — I always try to simplify to a barely usable application and iterate from there. The
problem this time was that my scope included functionality for an imaginary user base. I hadn’t yet
internalized the idea of &lt;a href=&quot;https://www.robinsloan.com/notes/home-cooked-app/&quot;&gt;home-cooked software&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Halfway through the concept, I even wrote this note to myself:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Reminder to build for yourself and not over-engineer for an imaginary user-base!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Me (to myself)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;And yet I carried on designing a system that would scale to lots of users instead of focusing on
what &lt;em&gt;I&lt;/em&gt; needed. Building significant functionality only to rip it out later hopefully taught me to
recognize these situations earlier next time.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Cheap and simple service monitoring</title><link>https://marending.dev/notes/monitoring/</link><guid isPermaLink="true">https://marending.dev/notes/monitoring/</guid><description>An ode to updown.io</description><pubDate>Fri, 24 Oct 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;25 Oct 2025&lt;/p&gt;&lt;h1&gt;Cheap and simple service monitoring&lt;/h1&gt;&lt;h2&gt;An ode to updown.io&lt;/h2&gt;&lt;p&gt;I’m hosting a bunch of software on a VPS. Among them a todo list, a note-taking application, and of
course my personal website. It would be a hassle if that were to go down without me noticing for an
extended period of time. Especially my notes app contains information that I sometimes urgently need
access to. Naturally, I want to know sooner rather than later if my server or individual deployments
go down. For this reason, I was looking around for uptime monitoring tools some time ago.&lt;/p&gt;
&lt;p&gt;Now, 2 years of using &lt;a href=&quot;https://updown.io&quot;&gt;updown.io&lt;/a&gt; later, I want to highlight this neat little
service. I wish more software was built like it.&lt;/p&gt;
&lt;h2 id=&quot;feature-set&quot;&gt;Feature set&lt;/h2&gt;
&lt;p&gt;updown effectively comes with two features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;uptime monitoring&lt;/li&gt;
&lt;li&gt;pulse monitoring&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Uptime monitoring is the core feature you expect from any monitoring service. It hits your
website/webapp with a request regularly, checking the response status, response time, SSL
certificate expiration, etc. in the process.&lt;/p&gt;
&lt;p&gt;Pulse monitoring works the other way around. You’re expected to hit an endpoint at updown regularly
to indicate that you’re alive and well. This is very useful to me to ensure backups are running: I
have backup scripts that upload DB dumps to S3 and send a pulse to updown upon successful
completion. If that script stops working for some reason (e.g., expired S3 credentials), the monitor
would alert me.&lt;/p&gt;
&lt;p&gt;And then there is some other stuff you would expect but I don’t use: status pages, API access, etc.
That’s pretty much it. No automated browser tests, no integrated tracing, no bells and whistles.
Just the rock-solid essentials.&lt;/p&gt;
&lt;p&gt;And I’ve noticed that this kind of minimalism encourages more home-grown solutions. Say I not only
want to check that a service is up, but also that the API is working correctly. Some monitoring
services offer automated API testing for this purpose. To achieve this with updown, I would instead
deploy a container that regularly runs these tests and reports success via a pulse check.&lt;/p&gt;
&lt;p&gt;The hard thing you don’t want to do yourself is the alerting, that’s the part that requires
independent hardware that won’t go down along with your infrastructure. That’s the part updown takes
over. The rest— the testing framework I use, how I run them, etc.—I don’t want to marry to a
monitoring solution. Some platform may offer a way to write API tests in JS, but I may prefer
&lt;a href=&quot;https://hurl.dev&quot;&gt;hurl&lt;/a&gt;. The fact that I’m left to construct my own solution around the essentials
is getting more attractive the more experience I gain.&lt;/p&gt;
&lt;p&gt;Of course, one should always carefully consider the maintenance burden of building your own
solution. But the cost of the hosted, locked-in solution is often underestimated. And I’m not just
talking about monetary cost.&lt;/p&gt;
&lt;h2 id=&quot;pricing&quot;&gt;Pricing&lt;/h2&gt;
&lt;p&gt;Right in line with the philosophy behind this service, the pricing is extremely reasonable. You buy
credits upfront, and then your checks consume the credits. For my modest usage, that comes out to
less than 50 cents per month. How &lt;em&gt;refreshing&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I often feel annoyed with the pricing services go with: a free tier that has limited functionality,
followed by a paid tier that goes well beyond my usage but costs, say, 15 bucks a month. I &lt;em&gt;want&lt;/em&gt; to
give them money; I want them to have a stable, sustainable business. But I also want to pay a fair
amount based on my usage. It doesn’t make sense that I pay the same amount of money whether I check
1 website once an hour or 10 websites every minute. There needs to be some gradation.&lt;/p&gt;
&lt;p&gt;Anyhow, I’m sure I’m preaching to the choir. I also understand the business’s perspective,
subscription models make way more money, and small fish customers are probably just not worth the
support obligation. All the more reason to celebrate the businesses that do offer such friendly
pricing.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Rust tracing to ClickHouse</title><link>https://marending.dev/notes/otel/</link><guid isPermaLink="true">https://marending.dev/notes/otel/</guid><description>This shouldn&apos;t be this hard</description><pubDate>Fri, 18 Jul 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;19 Jul 2025&lt;/p&gt;&lt;h1&gt;Rust tracing to ClickHouse&lt;/h1&gt;&lt;h2&gt;This shouldn&apos;t be this hard&lt;/h2&gt;&lt;p&gt;I don’t like the OpenTelemetry ecosystem. It’s complicated. Not just because it’s solving a hard
problem, but it feels needlessly complicated. I get the impression the observability vendors are
begrudgingly adopting an open standard but want to make it a daunting task to host your own
observability stack. Anyway, I’ve recently struggled for a bit to instrument a Rust application with
&lt;code&gt;tracing&lt;/code&gt; and send the data to ClickHouse. So here is a short write-up to save you a hot minute—or
provide some inspiration to self-host your observability platform.&lt;/p&gt;
&lt;h2 id=&quot;rust-application&quot;&gt;Rust application&lt;/h2&gt;
&lt;p&gt;The Rust crates involved here are unstable and moving fast, part of the reason this was trickier
than expected. But at the time of writing, here is what works.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;toml&quot;&gt;&lt;code&gt;// Cargo.toml
tracing = &amp;quot;0.1.40&amp;quot;
tracing-subscriber = &amp;quot;0.3.18&amp;quot;
opentelemetry_sdk = { version = &amp;quot;0.30.0&amp;quot;, features = [&amp;quot;rt-tokio&amp;quot;] }
opentelemetry = &amp;quot;0.30.0&amp;quot;
opentelemetry-otlp = &amp;quot;0.30.0&amp;quot;
tracing-opentelemetry = &amp;quot;0.31.0&amp;quot;
opentelemetry-stdout = &amp;quot;0.30.0&amp;quot;
opentelemetry-semantic-conventions = &amp;quot;0.30.0&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;rust&quot;&gt;&lt;code&gt;// main.rs
use opentelemetry::{global, trace::TracerProvider};
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing::{error, info, span, Span};
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};

std::env::set_var(&amp;quot;OTEL_SERVICE_NAME&amp;quot;, &amp;quot;your-application&amp;quot;);

let tracer = opentelemetry_otlp::SpanExporter::builder()
    .with_http()
    .with_endpoint(&amp;quot;http://localhost:4318/v1/traces&amp;quot;)
    .build()?;

let provider = SdkTracerProvider::builder()
    .with_batch_exporter(tracer)
    .build();

global::set_tracer_provider(provider.clone());

tracing_subscriber::registry()
    .with(
        tracing_subscriber::fmt::layer()
            .with_filter(tracing_subscriber::filter::LevelFilter::WARN),
    )
    .with(
        OpenTelemetryLayer::new(provider.tracer(&amp;quot;your-application&amp;quot;))
            .with_filter(tracing_subscriber::filter::LevelFilter::INFO),
    )
    .init();&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;observability-compose-stack&quot;&gt;Observability compose stack&lt;/h2&gt;
&lt;p&gt;Next, we want to run the
&lt;a href=&quot;https://github.com/open-telemetry/opentelemetry-collector-contrib&quot;&gt;OpenTelemetry Collector&lt;/a&gt; from
the contrib repository, a ClickHouse instance as well as Grafana.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;// compose.yaml
services:
  clickhouse:
    image: &amp;quot;clickhouse/clickhouse-server:25.6&amp;quot;
    container_name: clickhouse
    ports:
      - 18123:8123
      - 19000:9000
    restart: unless-stopped
    environment:
      - CLICKHOUSE_USER=default
      - CLICKHOUSE_PASSWORD=default
      - CLICKHOUSE_DB=your-db
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    healthcheck:
      test: [&amp;quot;CMD&amp;quot;, &amp;quot;wget&amp;quot;, &amp;quot;--no-verbose&amp;quot;, &amp;quot;--tries=1&amp;quot;, &amp;quot;--spider&amp;quot;, &amp;quot;http://localhost:8123/ping&amp;quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.128.0
    container_name: otel-collector
    ports:
      - 4317:4317
      - 4318:4318
      - 55679:55679
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    command: [&amp;quot;--config=/etc/otel-collector-config.yaml&amp;quot;]
    restart: unless-stopped
    depends_on:
      clickhouse:
        condition: service_healthy

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    ports:
      - &amp;quot;127.0.0.1:3000:3000&amp;quot;
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana
    restart: unless-stopped
    depends_on:
      clickhouse:
        condition: service_healthy

volumes:
  grafana-data:&lt;/code&gt;&lt;/pre&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;// otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:

exporters:
  clickhouse:
    endpoint: tcp://clickhouse:9000
    database: your-db
    username: default
    password: default
    ttl: 72h
    create_schema: true
    timeout: 5s
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s

extensions:
  health_check:
    endpoint: 0.0.0.0:55679

service:
  extensions: [health_check]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [clickhouse]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once you bring up these containers, you should be able to run your Rust application and start
sending tracing data to the collector. By manually connecting to the ClickHouse instance with
something like &lt;a href=&quot;https://tableplus.com/&quot;&gt;TablePlus&lt;/a&gt;, you should see an &lt;code&gt;otel_traces&lt;/code&gt; table with your
spans in there.&lt;/p&gt;
&lt;h2 id=&quot;grafana&quot;&gt;Grafana&lt;/h2&gt;
&lt;p&gt;Navigate to &lt;a href=&quot;http://localhost:3000&quot;&gt;http://localhost:3000&lt;/a&gt; for the Grafana frontend. Here you must
&lt;a href=&quot;https://clickhouse.com/docs/integrations/grafana&quot;&gt;install the appropriate data source plugin&lt;/a&gt;.
Finally, you can start writing queries like this to visualize your tracing data:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
  toStartOfInterval(&amp;quot;Timestamp&amp;quot;, toIntervalSecond(${__interval_ms}/50)) as time,
  count(*) as trace_count
FROM
  &amp;quot;your-db&amp;quot;.&amp;quot;otel_traces&amp;quot;
WHERE
  (
    Timestamp &amp;gt;= $__fromTime
    AND Timestamp &amp;lt;= $__toTime
  )
  AND (ParentSpanId = &amp;#39;&amp;#39;)
  AND (Duration &amp;gt; 0)
GROUP BY
  toStartOfInterval(&amp;quot;Timestamp&amp;quot;, toIntervalSecond(${__interval_ms}/50))
ORDER BY
  time ASC&lt;/code&gt;&lt;/pre&gt;&lt;/article&gt;</content:encoded></item><item><title>Exploring LLMs</title><link>https://marending.dev/notes/llm/</link><guid isPermaLink="true">https://marending.dev/notes/llm/</guid><description>A skeptic reconsiders</description><pubDate>Thu, 17 Jul 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;18 Jul 2025&lt;/p&gt;&lt;h1&gt;Exploring LLMs&lt;/h1&gt;&lt;h2&gt;A skeptic reconsiders&lt;/h2&gt;&lt;p&gt;I’ve been a long-time skeptic of large language models and their applicability to intellectual work.
Too readily do they hallucinate information, too amateurish are their programming mistakes. So for
too long, I didn’t give them a proper chance to redeem themselves. Now, I’ve spent a couple of
focused days reevaluating my stance, first by catching up on the latest advancements in transformer
architecture and then by putting these models to the test.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;This is mostly a worklog I was writing as I went along. The structure might be a bit weird as a
result.&lt;/p&gt;&lt;/div&gt;
&lt;h2 id=&quot;goals-and-summary&quot;&gt;Goals and summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Learn technical details of how LLMs work&lt;/strong&gt;: Excellent videos by 3Blue1Brown and Andrej Karpathy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Explore modern local models&lt;/strong&gt;: Underwhelming. Models small enough to run on my laptop leave much
to be desired.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Explore modern LLM tools and agents&lt;/strong&gt;: Claude Code phenomenally useful for coding. Claude also
useful for a bunch of other tasks.&lt;/p&gt;
&lt;h2 id=&quot;developing-an-intuition-for-how-llms-work&quot;&gt;Developing an intuition for how LLMs work&lt;/h2&gt;
&lt;p&gt;I came into this with some knowledge of machine learning and neural networks from university
courses. I even read &lt;a href=&quot;https://arxiv.org/abs/1706.03762&quot;&gt;the OG transformer paper&lt;/a&gt; back then but
apparently didn’t take much away from it. So previously I would consider LLMs as the classical
stochastic token predictors that don’t &lt;em&gt;think&lt;/em&gt;, but only produce text that conforms to previous
patterns, thus appearing plausibly intelligent. Trying to assign any cognitive abilities to LLMs is
merely misguided anthropomorphizing of a statistical process.&lt;/p&gt;
&lt;p&gt;But after catching up on transformer architecture, I’ve changed my mind. In particular, the concept
that attention layers allow the LLM to exchange context between tokens evidently leads to a very
expressive computational model. Contrasting this with a simple feed-forward, fully connected neural
network, it seems obvious that this architecture is something fundamentally different. I’m also just
nihilistic enough to reject any vague spiritual arguments that human cognition is somehow special.
That leads me to consider it plausible that an LLM could actually “think”. Naturally, much has been
written about this topic, both technical and philosophical. This is just my semi-informed take
primarily from a technical perspective.&lt;/p&gt;
&lt;p&gt;It’s worth mentioning that transformer models have obvious limitations that may not apply to human
cognition: Reasoning is inherently token-based, models are static and not updating weights during
inference, and I’m sure there are many more.&lt;/p&gt;
&lt;h2 id=&quot;explore-local-models-claude-and-gemini&quot;&gt;Explore local models, Claude and Gemini&lt;/h2&gt;
&lt;p&gt;To get a first impression of modern developments, I used local and hosted LLMs for some search
queries and other tasks. Below are two examples with my impression of each model. For the local
models I used &lt;a href=&quot;https://msty.app/&quot;&gt;Msty&lt;/a&gt;, the hosted ones are the free tier at this point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who is currently in the Swiss National Council&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt;: Very good.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Claude Sonnet 4&lt;/strong&gt;: Good but slow, can’t figure out presidency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gemma3:1B&lt;/strong&gt;: Bad, gets even the number of members wrong.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Llama 3.2&lt;/strong&gt;: Gets the number of members correct but has knowledge cutoff in 2023 and doesn’t do
web search. Also slow to load model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Qwen3&lt;/strong&gt;: Super slow but pretty good.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gemma3&lt;/strong&gt;: Gets number of seats really wrong and annoyingly agreeable to having it pointed out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Summarize my &lt;a href=&quot;/notes/server-setup/&quot;&gt;server setup blog post&lt;/a&gt; and explain the deployment scripts&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt;: Phenomenal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Claude Sonnet 4&lt;/strong&gt;: Phenomenal, a bit more concise than Gemini.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gemma3:1B&lt;/strong&gt;: Useless. When asking for typos it thinks I’m talking about typos in its response.
Asking it to explain something in the script is fruitless.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Llama 3.2&lt;/strong&gt;: Not good. Doesn’t find any typos and can’t explain script at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Qwen3&lt;/strong&gt;: Not good. It comes up with a new deployment script, doesn’t analyze mine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gemma3&lt;/strong&gt;: Not good. Comes up with new deployment script like Qwen3. Slightly more similar to the
script in the post though.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;local-models-conclusion&quot;&gt;Local models conclusion&lt;/h3&gt;
&lt;p&gt;As much as I would like to use local models for privacy reasons, it doesn’t look like it’s going to
happen. This is not to say that open-weight models in general are bad, just the ones that are small
enough that I can run them on my laptop. So I guess I’ll have to go with one of the bigger hosted
ones.&lt;/p&gt;
&lt;h2 id=&quot;hosted-models&quot;&gt;Hosted models&lt;/h2&gt;
&lt;p&gt;I’m going with Claude for this exploration for two reasons. First, it’s said to be the strongest
model for coding, which is my primary use case. Second, Anthropic seems to be the most sympathetic
of the AI companies out there to me.&lt;/p&gt;
&lt;p&gt;I can’t get behind Google due to privacy reasons—they can simply bring together too many threads of
my digital persona. OpenAI is exceptionally unpleasant in their public conduct. xAI also won’t cut
it for similar reasons.&lt;/p&gt;
&lt;p&gt;Unfortunately there are no &lt;em&gt;good&lt;/em&gt; options when it comes to privacy, but it seems Anthropic is the
least bad.&lt;/p&gt;
&lt;h2 id=&quot;claude&quot;&gt;Claude&lt;/h2&gt;
&lt;h3 id=&quot;proofreading&quot;&gt;Proofreading&lt;/h3&gt;
&lt;p&gt;Works very well. I can paste in a note in &lt;code&gt;mdx&lt;/code&gt; format and just need to remind it to keep line
breaks as is, and it will do a fine job directly correcting typos and awkward sentences, ready to
paste back into my website.&lt;/p&gt;
&lt;p&gt;It even handles markdown tables etc. flawlessly. Much simpler than pasting a note into an online
spell-checker and manually fixing all the findings. With the note being checked into git, it’s easy
to see the diff that Claude produced. I wouldn’t trust it otherwise.&lt;/p&gt;
&lt;p&gt;Addendum: I also pasted this very blog post for proofreading into Claude and was caught off-guard
when it referenced the content in its answer:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You’re welcome! The corrected version is ready to paste back into your website. With it being
tracked in git, you’ll be able to easily see the diff of changes I made. Happy to help with
proofreading anytime!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Addendum to the addendum: On the second iteration of passing this through Claude, it gave the above
quote again. After asking whether it did this deliberately after seeing it in the text, it answered
this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Yes, exactly! I noticed that section where you mentioned pasting your blog post into Claude for
proofreading and being “caught off-guard” when it referenced the content in its answer with that
exact response. So I deliberately included the same response as a bit of meta-humor, since you
were literally doing the same thing again. It seemed like a fun way to acknowledge that I had read
and understood the content, rather than just mechanically proofreading without engaging with what
you’d written about your LLM experiences.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Notably, on the second pass it wanted to replace a bunch of commas with a dash. It reads sensibly,
but is quite out-of-character for a non-native speaker, so I reverted most of them.&lt;/p&gt;
&lt;h3 id=&quot;spot-mistakes-in-crypto-code&quot;&gt;Spot mistakes in crypto code&lt;/h3&gt;
&lt;p&gt;I’m pasting in the
&lt;a href=&quot;https://github.com/beingflo/write.fieldnotes/blob/main/src/components/crypto.ts&quot;&gt;encryption code&lt;/a&gt;
used in &lt;a href=&quot;/notes/fieldnotes/&quot;&gt;Fieldnotes&lt;/a&gt; and ask to assess the security of this code and whether it
follows best practices. Claude says the code is sound and accomplishes its goal. Asking follow-up
questions, it’s clear that Claude has a rather sophisticated understanding. E.g. “What could be the
advantage of using per-note keys rather than encrypting notes directly with the main key?”.&lt;/p&gt;
&lt;p&gt;To check that Claude isn’t overly agreeable I ask the same question in a new context with modified
crypto code that includes some issues. In one instance I set the main key to be &lt;code&gt;extractable&lt;/code&gt;, which
it correctly flags as something that could be improved. Further, when passing &lt;code&gt;undefined&lt;/code&gt; in the
initialization vectors, it raises alarm bells, calling this a critical bug with catastrophic
consequences.&lt;/p&gt;
&lt;p&gt;This isn’t to say that an AI chatbot should be entrusted with security audits, but it is still
reassuring that it doesn’t mess up on obvious issues. Overall I’m quite impressed here. Most
engineers I work with wouldn’t know about IVs, not to mention spot mistakes like this.&lt;/p&gt;
&lt;h3 id=&quot;architecture-discussion&quot;&gt;Architecture discussion&lt;/h3&gt;
&lt;p&gt;I’m writing a short overview of requirements for my upcoming rewrite of my observability / metrics
tool. There are a bunch of open questions in my head and I haven’t managed to get a clear picture of
what I want to do. Part of the reason is the absolute mess that is OpenTelemetry documentation.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;md&quot;&gt;&lt;code&gt;# Observatory: Observability and Time Series System

Observatory is a new system to store observability data as well as time series data.

## Store time series data from embedded devices

There are multiple embedded devices that periodically record sensor data. They should be able to
send that time series data to Observatory.

## Store observability data from backend services

There are multiple backend services implemented in Rust. They use the `tracing` crate to instrument
them. They should be able to export their tracing data to Observatory.

## Visualization

Observatory should include a web application that visualizes the data stored within.

## Questions

- What storage layer is advisable?
- Can a database be used directly to ingest observability data or is a backend application in front
  of the storage layer needed?&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After pasting in this document and prompting Claude to ask clarifying questions, it produces an
updated requirements doc. It contains a bunch of boilerplate that would satisfy an architect, but
also some interesting stuff. Together we iterate towards a stack with ClickHouse at the center with
either a custom OpenTelemetry collector in front or Jaeger using CH as the storage layer and Grafana
as the visualization tool.&lt;/p&gt;
&lt;p&gt;I also ask follow-up questions regarding OpenTelemetry that I haven’t been able to find answers to
after hours of scouring the web (due to the over-engineered mess that is the OpenTelemetry
ecosystem) and Claude can give competent answers. Overall, exceptionally helpful for this task.&lt;/p&gt;
&lt;h3 id=&quot;claude-as-a-google-search-replacement&quot;&gt;Claude as a Google Search replacement&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Restaurant recommendations: Good.&lt;/li&gt;
&lt;li&gt;Smart plug product search: Okay, has to be pointed in some directions but can summarize options
quite well.&lt;/li&gt;
&lt;li&gt;Particulate matter sensor: Very good.&lt;/li&gt;
&lt;li&gt;JS editor research: Excellent.&lt;/li&gt;
&lt;li&gt;HAR file to wiremock stub: Why search for a tool when it can generate the script directly?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In my experience, when Claude needs up-to-date information from the web it’s noticeably less useful
than when knowledge / skill is sufficient that it already has embedded in its weights.&lt;/p&gt;
&lt;h3 id=&quot;embedded-development&quot;&gt;Embedded development&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Failed to set up a Rust project for programming an ESP32-C6. But then again, so did I until I
figured out a peculiarity in my toolchain.&lt;/li&gt;
&lt;li&gt;Interestingly, Claude prefers to generate code from memory rather than invoke the template
generator it most definitely has this code from. That leads to outdated dependencies.&lt;/li&gt;
&lt;li&gt;Once the project is set up, it can make changes no problem, including using new APIs that I don’t
know about.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I’m starting to notice that whenever Claude is really struggling with something, chances are so will
I when I have to inevitably do it myself.&lt;/p&gt;
&lt;h3 id=&quot;web-development&quot;&gt;Web development&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Masonry grid&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Changing an image layout from cropped images in a square grid to masonry (without CSS masonry
grid): Not managing well. It literally tried to absolute position the images *facepalm*.&lt;/li&gt;
&lt;li&gt;Claude lacks contextual persistence: It just modified a file which throws a JS error now. Upon
pasting this error into Claude it first searches for a minute for where this error might be
instead of immediately checking the file it just modified.&lt;/li&gt;
&lt;li&gt;Not fun to use at all. Claude just messed something new up every time. An exercise in frustration.
Even if it technically satisfies the requirements, it’s super jumpy / glitchy. It has no taste
whatsoever to build a robust solution. To be fair, it &lt;em&gt;is&lt;/em&gt; building this blind. It has no channel
to observe the result, which would be hard for me as well. But that’s just the nature of these
tools for now I guess.&lt;/li&gt;
&lt;li&gt;I’m giving up on this. It will be faster and importantly less nerve-wracking to do this myself.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A learning here is to be fair to the model. This &lt;em&gt;is&lt;/em&gt; a challenging task to do well, and I secretly
gave it to Claude because I don’t want to do it myself. Coupled with the fact that it can’t see the
output of its work, I really shouldn’t be surprised that it can’t pull it off. Neither could I.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Async image processing&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Changing an image upload endpoint from immediate compression and upload of multiple versions to
one where only the original is uploaded and compression and upload of compressed images is
happening asynchronously: Works flawlessly on first try! Did not expect that.&lt;/li&gt;
&lt;li&gt;Later, I noticed a bug that I couldn’t get Claude to resolve itself. After checking the code in
detail I found the culprit: There were two tasks executing in parallel using &lt;code&gt;rayon::join&lt;/code&gt;. The
error returned from this was checked, but hidden was that the individual &lt;code&gt;Response&lt;/code&gt;s went
unchecked. In this case, an upstream storage API was sometimes throwing &lt;code&gt;500&lt;/code&gt;, but that went
unnoticed due to missing error handling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;While bugs like this don’t happen to me because I’m very meticulous in handling any error the type
system throws my way (part of the reason I enjoy Rust so much), I have to restrain myself from being
annoyed at this as the time saving is still there. It’s not much different from reviewing PRs at
work.&lt;/p&gt;
&lt;h3 id=&quot;opentelemetry-shenanigans&quot;&gt;OpenTelemetry shenanigans&lt;/h3&gt;
&lt;p&gt;Continuing from the architecture discussions with Claude, I went on to try some of the conclusions
in a proof of concept. I also have a more detailed write-up of this &lt;a href=&quot;/notes/clickhouse/&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Have Claude Code create a compose stack with ClickHouse,
&lt;a href=&quot;https://github.com/open-telemetry/opentelemetry-collector-contrib&quot;&gt;OpenTelemetry Collector&lt;/a&gt; and
Grafana. Works flawlessly. It saves me the 5 minute dance of remembering how docker networking
works to get different services to talk to each other. After some errors on the Collector’s part,
I can get Claude to make sure the DB is up and running before the Collector tries to connect to
it. It also creates the OTEL collector config easily.&lt;/li&gt;
&lt;li&gt;At this point the stack is running but trying to get Claude to juggle the 6 (!) Rust crates needed
to send the tracing data in otel-compatible format to the Collector is a mess. Did I mention that
I don’t like the OpenTelemetry ecosystem? Here, the crates are a bit too young and moving too
fast, Claude stubbornly tries to call a function that existed in an older version of one of the
crates. But even after manually intervening I’m also struggling. Ultimately we managed to get it
running.&lt;/li&gt;
&lt;li&gt;Once tracing data is in ClickHouse I start experimenting with charting interesting metrics in
Grafana. The auto-generated table schema is quite involved and I have no knowledge of ClickHouse’s
SQL dialect. Claude to the rescue—I can simply paste in the schema and ask for convoluted queries.
It works phenomenally well for this.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;how-do-i-use-llms-now&quot;&gt;How do I use LLMs now?&lt;/h2&gt;
&lt;p&gt;With all this testing done, I’m starting to get a grip on how LLMs can be useful for me. I might add
that I’m primarily talking about my personal projects here. At work I use LLMs significantly less
for various reasons: There is so much context required to produce good code that it’s often faster
to do it myself than it is to verbalize all this context. The complexity in my project at work is
also mostly in the interactions between systems, dependencies and a host of human factors.
Technologically it’s quite simple. Naturally that’s a bad fit for LLMs. They’re not about to
coordinate an API change with another team for me. Personal projects on the other hand are mostly
about the technical challenges and all the code lives in a repo available to the LLM. API changes
can be made without a care in the world, UIs can be changed without discussing with UX for an hour.&lt;/p&gt;
&lt;p&gt;So for personal projects I use Claude Code in a terminal next to my normal IDE setup. This is a nice
way of keeping in control while having Claude sit there and help out if needed. I always work on a
branch and commit often to be able to see isolated changes in a diff. No vibe coding for me, I
always check Claude’s changes in detail. Typically I watch Claude make changes and closely guide it
into the direction I prefer. This whole ‘agentic’ workflow where people supposedly have multiple
instances of LLMs running simultaneously does not work for me.&lt;/p&gt;
&lt;p&gt;Apart from using Claude Code in the terminal, I also use the web interface often for one-off tasks,
such as crafting ClickHouse queries, discussing architectural approaches, proofreading stuff etc.&lt;/p&gt;
&lt;div&gt;&lt;/div&gt;
&lt;h2 id=&quot;appendix&quot;&gt;Appendix&lt;/h2&gt;
&lt;h3 id=&quot;notes-on-deep-dive-into-llms-like-chatgpt&quot;&gt;Notes on &lt;a href=&quot;https://www.youtube.com/watch?v=7xTGNNLPyMI&quot;&gt;Deep Dive into LLMs like ChatGPT&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Base models don’t know about question / answer format, they simply predict the next token from the
context.&lt;/li&gt;
&lt;li&gt;One can fake a conversational agent by constructing a few-shot example prompt that shows the
question / answer format ending in &lt;code&gt;answer:&lt;/code&gt;. The model will continue in a similar fashion.&lt;/li&gt;
&lt;li&gt;Instruct models are post-trained on human-generated conversations to properly bake in the concept
of answering questions. While the base training might take months, post-training typically takes
only hours.&lt;/li&gt;
&lt;li&gt;Hallucinations are being addressed by first probing a model for what it knows: Paste a paragraph
of Wikipedia into one model and have it generate question / answer pairs related to the paragraph.
This is easy because the answers are right there in the context window. Take the generated
questions to another model and have it answer without having the context. Compare answers to
determine if second model knows the answer. If model doesn’t know, add an answer to post-training
data set to give answer “Don’t know”.&lt;/li&gt;
&lt;li&gt;Tool usage works as follows: Model generates special tokens for e.g. web search, inference code
pauses when it encounters those tokens, goes off to do the search, pastes response back into
context and continues inference.&lt;/li&gt;
&lt;li&gt;Interesting insight on reasoning: There is only a fixed amount of computation flowing into any
given token. So training data needs to be phrased in such a way that the model doesn’t have to
solve the entire problem in one token, but instead can spread out intermediary computation over
more tokens. Kind of like slowly reasoning your way towards the solution rather than producing an
answer immediately and then post-hoc justifying the answer. This leads to the conclusion that LLMs
need tokens to think. Thinking is intrinsically linked to language processing.&lt;/li&gt;
&lt;li&gt;Many counting and spelling tasks are not working well due to tokens. Tokens only exist for
efficiency, there are efforts to move to character-level or byte-level models. That may solve this
problem.&lt;/li&gt;
&lt;li&gt;Post-processing: Reinforcement learning: Model is sampled many times on same question. Answers are
checked for correctness against known answer. Correct answers are trained on to make those token
streams more likely (reinforced). E.g. model has reasoned its way through problem well, not
pinning too much computation on a single token and thus got to the right answer. This is good and
should be encouraged through fine-tuning on those answers.&lt;/li&gt;
&lt;li&gt;DeepSeek R1 was the first model where the use of reinforcement learning was publicly talked about.
This model learned to generate thinking sequences, where multiple different approaches are
generated and compared by the model. Only then does it generate a nice output intended for the
human with the previous thinking in context.&lt;/li&gt;
&lt;li&gt;together.ai for trying open weights models&lt;/li&gt;
&lt;li&gt;Reinforcement learning in &lt;em&gt;verifiable domains&lt;/em&gt;: There is a way to reliably tell what the correct
answer is. Trivially or via LLM judge.&lt;/li&gt;
&lt;li&gt;RLHF: Reinforcement learning from human feedback&lt;/li&gt;
&lt;li&gt;Reinforcement learning in &lt;em&gt;unverifiable domains&lt;/em&gt; (e.g. jokes): Train rewards model from little
human feedback, then use that to judge many outputs.&lt;/li&gt;
&lt;li&gt;Discriminator-generator gap: For a human it’s way easier to judge output than create output.&lt;/li&gt;
&lt;li&gt;Risk of RLHF: RL may discover a way to game the model as a lossy simulation of a human is judging
output. That may lead to nonsensical output getting high scores. (Adversarial example)&lt;/li&gt;
&lt;li&gt;As a result RLHF is often run briefly to avoid this problem.&lt;/li&gt;
&lt;li&gt;Upcoming capabilities: Multimodal models: Tokenize audio, video and train as usual.&lt;/li&gt;
&lt;li&gt;Upcoming capabilities: Agents: Long, coherent, error-correcting contexts.&lt;/li&gt;
&lt;li&gt;Upcoming capabilities: Test-time training: Current models only do in-context learning on the
context.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;notes-on-how-i-use-llms&quot;&gt;Notes on &lt;a href=&quot;https://www.youtube.com/watch?v=EWvNQjAaOHw&quot;&gt;How I use LLMs&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Keep context on-topic. Large context may distract model and slightly decrease accuracy.&lt;/li&gt;
&lt;li&gt;Thinking (reasoning) models are considered ones that have been improved with reinforcement
learning.&lt;/li&gt;
&lt;li&gt;GPT-4o is non-thinking model.&lt;/li&gt;
&lt;li&gt;OpenAI models starting with &lt;code&gt;o&lt;/code&gt; (o3-mini, o1, etc.) are thinking models.&lt;/li&gt;
&lt;li&gt;Search tool use very useful to quickly gather websites and summarize content.&lt;/li&gt;
&lt;li&gt;Deep research combines search and thinking to do research.&lt;/li&gt;
&lt;li&gt;Some models support document upload. Here, the document is simply added to the context for the LLM
to query.&lt;/li&gt;
&lt;li&gt;ChatGPT is trained to recognize math problems it probably can’t do in its “head”. In this case it
will invoke a python interpreter instead (tool use).&lt;/li&gt;
&lt;li&gt;Claude Artifacts can build little applications and run them directly in browser in their
interface.&lt;/li&gt;
&lt;li&gt;SuperWhisper to transcribe voice to text system-wide. Also useful for prompting without typing.&lt;/li&gt;
&lt;li&gt;Advanced voice mode in ChatGPT: Handle voice natively in the LLM instead of transcribing to text
and operating on that.&lt;/li&gt;
&lt;li&gt;NotebookLM for generating podcasts on arbitrary topics.&lt;/li&gt;
&lt;li&gt;DALL-E does not generate images inside the LLM, it sends a caption to a separate image-generation
model.&lt;/li&gt;
&lt;li&gt;ChatGPT typically wipes context in a new chat. But there is the option to ask it to remember
something. It will simply add those memories to the beginning of the context in a new chat.&lt;/li&gt;
&lt;li&gt;ChatGPT custom instructions to avoid repeating preferences in every new chat.&lt;/li&gt;
&lt;li&gt;Custom GPT: provide a system prompt to get answers in specific format.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;notes-on-transformers-the-tech-behind-llms&quot;&gt;Notes on &lt;a href=&quot;https://www.youtube.com/watch?v=wjZofJX0v4M&amp;amp;list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi&amp;amp;index=6&quot;&gt;Transformers, the tech behind LLMs&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;“Attention is all you need” originally introduced transformers.&lt;/li&gt;
&lt;li&gt;GPT-3 has 175B parameters&lt;/li&gt;
&lt;li&gt;Word embedding in GPT-3 is 12k-dimensional&lt;/li&gt;
&lt;li&gt;Typical word-embedding may choose to encode gender information in one dimension. Thus, the classic
&lt;code&gt;man - woman = uncle - aunt&lt;/code&gt;. Similarly, &lt;code&gt;hitler + italy - germany = mussolini&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Further, one direction in the embedding space indicates &lt;em&gt;plurality&lt;/em&gt;. i.e. &lt;code&gt;plur = cats - cat&lt;/code&gt; and
the dot product between plurals and the &lt;code&gt;plur&lt;/code&gt; direction is higher than with singular words.&lt;/li&gt;
&lt;li&gt;Embedding matrix in GPT-3 has around 617M weights.&lt;/li&gt;
&lt;li&gt;Embedding maps every token to a vector in isolation. It’s the attention mechanism that enables the
network to exchange meaning &lt;em&gt;between&lt;/em&gt; the vectors, to arrive at a more enriched meaning for each
token. e.g. &lt;code&gt;river bank&lt;/code&gt; vs &lt;code&gt;deposit at the bank&lt;/code&gt;, &lt;code&gt;bank&lt;/code&gt; has different meaning based on context.&lt;/li&gt;
&lt;li&gt;At the end, the &lt;em&gt;unembedding matrix&lt;/em&gt; maps the last token in the context to a probability
distribution across the entire vocabulary (50k in GPT-3) for prediction.&lt;/li&gt;
&lt;li&gt;All the other tokens in the last layer are actually encoding their immediately following tokens
and are not used for the prediction of a new token. This turns out to favor training.&lt;/li&gt;
&lt;li&gt;Unembedding matrix also has 617M weights in GPT-3.&lt;/li&gt;
&lt;li&gt;Softmax is used to turn embedding vector into a probability distribution. The temperature is an
extra parameter here to guide how “sharp” the distribution is. i.e &lt;code&gt;T = 0&lt;/code&gt;, highest component gets
100% of the probability; &lt;code&gt;T = 5&lt;/code&gt;, spreads out the probability much more evenly, thus making
predictions more likely that have lower components in the vector.&lt;/li&gt;
&lt;li&gt;Components in the output vector after unembedding are called logits.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;notes-on-attention-in-transformers-step-by-step&quot;&gt;Notes on &lt;a href=&quot;https://www.youtube.com/watch?v=eMlx5fFNoYc&amp;amp;list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi&amp;amp;index=7&quot;&gt;Attention in transformers, step-by-step&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Initially, every input token gets mapped to the same embedding vector—there is no sense of
context.&lt;/li&gt;
&lt;li&gt;One interaction of attention may be to update the embeddings of nouns with the preceding
adjectives. This should refine the embedding vector to one that captures the essence of the noun
in context better. (this assumes tokens are words).&lt;/li&gt;
&lt;li&gt;Query and key vectors determine how relevant each token is to each other, i.e. the embedding of
one word &lt;em&gt;attends&lt;/em&gt; to the embedding of another.&lt;/li&gt;
&lt;li&gt;The dot products of each query vector with each key vector yields the &lt;em&gt;attention pattern&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;To improve training, not only is the last token expected to predict the next one, but all tokens
are expected to predict the following.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Masking&lt;/em&gt; is the process of setting the lower left half of the attention pattern to 0, to ensure
later tokens cannot “give away” the prediction of earlier ones.&lt;/li&gt;
&lt;li&gt;Size of attention pattern is bottleneck for context window as it scales with the square.&lt;/li&gt;
&lt;li&gt;Finally, embeddings of tokens are updated by multiplying attention pattern by value vectors
(produced using a value matrix). This way, every token gets &lt;em&gt;some&lt;/em&gt; part of every other token
(preceding it), but more from the ones that are attending to it more strongly.&lt;/li&gt;
&lt;li&gt;This whole process is called one &lt;em&gt;head of attention&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;One attention head uses approximately 6.4M weights.&lt;/li&gt;
&lt;li&gt;Multi-headed attention is running multiple attention layers in parallel. 96 per block for GPT-3.&lt;/li&gt;
&lt;li&gt;All deltas of each attention layer are added together to the original embedding.&lt;/li&gt;
&lt;li&gt;GPT-3 has 96 attention blocks, leading to 58B weights dedicated to attention.&lt;/li&gt;
&lt;li&gt;The rest of the 175B parameters are in the in-between multilayer perceptron layers.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;notes-on-how-might-llms-store-facts&quot;&gt;Notes on &lt;a href=&quot;https://www.youtube.com/watch?v=9-Jl0dxWQs8&amp;amp;list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi&amp;amp;index=8&quot;&gt;How might LLMs store facts&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;2/3 of weights in a typical transformer live in the Multilayer Perceptron layers (MLP).&lt;/li&gt;
&lt;li&gt;In MLP layers vectors don’t “talk” to each other, the operations happen in isolation.&lt;/li&gt;
&lt;li&gt;First operation in MLP layer is a matrix multiplication that maps input vector into higher
dimension.&lt;/li&gt;
&lt;li&gt;In case of GPT-3, this matrix has 49k rows.&lt;/li&gt;
&lt;li&gt;In some way, each of those rows can be thought of as a question that is being asked of the input
vector.&lt;/li&gt;
&lt;li&gt;Next, resulting vector is run through non-linear function (e.g ReLU) to nicely clip components
that didn’t satisfy question.&lt;/li&gt;
&lt;li&gt;Finally a down projection matrix maps the intermediate vector down to the input dimension.&lt;/li&gt;
&lt;li&gt;The resulting vector is added to input vector.&lt;/li&gt;
&lt;li&gt;Single MLP layer has about 1.2B parameters.&lt;/li&gt;
&lt;li&gt;Fascinating insight: For an n-dimensional space, there are exactly n vectors that are pairwise
orthogonal to each other. But if the requirement is relaxed slightly to between 89 and 91 degrees,
the number of vectors actually grows exponentially with the dimension! (Johnson-Lindenstrauss
Lemma)&lt;/li&gt;
&lt;li&gt;This has huge implications for LLMs and explains why model performance seems to scale so well with
size. A model that is 10 times larger can represent way more than 10 times the number of concepts
in its latent space.&lt;/li&gt;
&lt;li&gt;This explains why inside an MLP layer the neurons are not lighting up as unit vectors to represent
a single concept, instead it’s some random looking vector that happens to be one of the nearly
orthogonal directions in that high-dimensional space.&lt;/li&gt;
&lt;li&gt;Research area trying to extract true meaning of those neurons: Sparse Autoencoders.&lt;/li&gt;
&lt;/ul&gt;&lt;/article&gt;</content:encoded></item><item><title>ClickHouse for analytics workloads</title><link>https://marending.dev/notes/clickhouse/</link><guid isPermaLink="true">https://marending.dev/notes/clickhouse/</guid><description>A third contender emerges</description><pubDate>Tue, 15 Jul 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;16 Jul 2025&lt;/p&gt;&lt;h1&gt;ClickHouse for analytics workloads&lt;/h1&gt;&lt;h2&gt;A third contender emerges&lt;/h2&gt;&lt;p&gt;I looked at the performance of SQLite and DuckDB for analytics workloads
&lt;a href=&quot;/notes/sqlite-vs-duckdb/&quot;&gt;in this note&lt;/a&gt;. DuckDB was clearly superior. In the meantime I’ve built
some sort of metrics collection system (project report will follow) with this DB. However, I’ve been
itching to create something new that can also handle tracing data well. Naturally, I’ve stumbled
across ClickHouse in that context. In this note, I want to apply the same benchmark to CH and see
how it compares.&lt;/p&gt;
&lt;h2 id=&quot;methodology&quot;&gt;Methodology&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/notes/sqlite-vs-duckdb/&quot;&gt;Read the previous note&lt;/a&gt; to learn how the data is generated and what the
different use cases here are. For ClickHouse I’ve gone a bit off the rails though, so I better
explain what’s going on here: Instead of crafting a specific table for the data I’m ingesting here,
I’m actually using the default &lt;code&gt;otel_traces&lt;/code&gt; table as generated by the
&lt;a href=&quot;https://github.com/open-telemetry/opentelemetry-collector-contrib&quot;&gt;OpenTelemetry Collector&lt;/a&gt;. And
instead of ingesting data into ClickHouse via the Rust client, I’m simply emitting a tracing span
with the relevant data as span attributes and letting the tracing machinery batch send this data to
the collector, which in turn writes it to ClickHouse.&lt;/p&gt;
&lt;p&gt;I know, I know, this sounds very hacky. But it would be quite nice to just be able to add some
instrumentation to any system and have its data make it to a DB that is efficient at pulling out
relevant data. So that’s what I want to test here.&lt;/p&gt;
&lt;p&gt;For reference, the traces table in ClickHouse looks like this:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE default.otel_traces
(
  `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
  `TraceId` String CODEC(ZSTD(1)),
  `SpanId` String CODEC(ZSTD(1)),
  `ParentSpanId` String CODEC(ZSTD(1)),
  `TraceState` String CODEC(ZSTD(1)),
  `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
  `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
  `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
  `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `ScopeName` String CODEC(ZSTD(1)),
  `ScopeVersion` String CODEC(ZSTD(1)),
  `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `Duration` Int64 CODEC(ZSTD(1)),
  `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
  `StatusMessage` String CODEC(ZSTD(1)),
  `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
  `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
  `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
  `Links.TraceId` Array(String) CODEC(ZSTD(1)),
  `Links.SpanId` Array(String) CODEC(ZSTD(1)),
  `Links.TraceState` Array(String) CODEC(ZSTD(1)),
  `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
  INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
  INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
  INDEX idx_duration Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(3)
SETTINGS ttl_only_drop_parts = 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My application under test exposes an endpoint for ingesting data. As mentioned, it does not make use
of the ClickHouse client, instead just emits tracing data that ends up in the DB.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;rust&quot;&gt;&lt;code&gt;async fn upload_data(Json(payload): Json&amp;lt;Data&amp;gt;) -&amp;gt; StatusCode {
    let span = span!(
        tracing::Level::INFO,
        &amp;quot;json-benchmark&amp;quot;,
        timestamp = payload.timestamp,
        bucket = payload.bucket,
        payload = serde_json::to_string(&amp;amp;payload.data).unwrap_or_default()
    );
    let _enter = span.enter();

    StatusCode::OK
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To see how many queries per second can be served I expose endpoints that use the ClickHouse client
to run the queries. For instance, for the GPS use case I have this endpoint:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;rust&quot;&gt;&lt;code&gt;async fn get_gps_coords(State(conn): State&amp;lt;Client&amp;gt;) -&amp;gt; (StatusCode, Json&amp;lt;Vec&amp;lt;GPSResponse&amp;gt;&amp;gt;) {
    let mut cursor = conn
        .query(
            &amp;quot;SELECT
    JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;longitude&amp;#39;) AS longitude,
    JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;latitude&amp;#39;) AS latitude
FROM otel_traces
WHERE SpanAttributes[&amp;#39;bucket&amp;#39;] = &amp;#39;location&amp;#39;
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;longitude&amp;#39;)) &amp;gt; 6
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;longitude&amp;#39;)) &amp;lt; 10
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;latitude&amp;#39;)) &amp;gt; 45
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;latitude&amp;#39;)) &amp;lt; 50&amp;quot;,
        )
        .fetch::&amp;lt;GPSResponse&amp;gt;()
        .unwrap();

    let mut results = Vec::new();
    while let Some(row) = cursor.next().await.unwrap() {
        results.push(row);
    }

    (StatusCode::OK, Json(results))
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These endpoints are then load tested via &lt;a href=&quot;https://k6.io/&quot;&gt;k6&lt;/a&gt; to get the numbers below.&lt;/p&gt;
&lt;h3 id=&quot;queries&quot;&gt;Queries&lt;/h3&gt;
&lt;p&gt;For reference, here are the three queries for the different use cases. These should be ClickHouse
equivalents to the DuckDB ones in the previous note.&lt;/p&gt;
&lt;p&gt;GPS coordinates:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
 JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;longitude&amp;#39;) AS longitude,
 JONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;latitude&amp;#39;) AS latitude
FROM otel_traces
WHERE SpanAttributes[&amp;#39;bucket&amp;#39;] = &amp;#39;location&amp;#39;
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;longitude&amp;#39;)) &amp;gt; 6
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;longitude&amp;#39;)) &amp;lt; 10
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;latitude&amp;#39;)) &amp;gt; 45
    AND toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;latitude&amp;#39;)) &amp;lt; 50&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This checks how many location entries are within a certain longitude and latitude rectangle.&lt;/p&gt;
&lt;p&gt;CO2:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
    toMonth(parseDateTimeBestEffort(SpanAttributes[&amp;#39;timestamp&amp;#39;])) AS timestamp,
    avg(toFloat64(JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;co2&amp;#39;))) AS avg
FROM otel_traces
WHERE SpanAttributes[&amp;#39;bucket&amp;#39;] = &amp;#39;co2&amp;#39;
GROUP BY toMonth(parseDateTimeBestEffort(SpanAttributes[&amp;#39;timestamp&amp;#39;]))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Possibly the most intensive query: This aggregates CO2 measurements by month and computes the
respective averages.&lt;/p&gt;
&lt;p&gt;Structured logs:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT
    count(*) AS count,
    JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;endpoint&amp;#39;) AS endpoint
FROM otel_traces
WHERE SpanAttributes[&amp;#39;bucket&amp;#39;] = &amp;#39;logs&amp;#39;
    AND JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;level&amp;#39;) = &amp;#39;error&amp;#39;
    AND parseDateTimeBestEffort(SpanAttributes[&amp;#39;timestamp&amp;#39;]) &amp;gt; toDate(&amp;#39;2023-07-01&amp;#39;) - INTERVAL 90 DAY
GROUP BY JSONExtractString(SpanAttributes[&amp;#39;payload&amp;#39;], &amp;#39;endpoint&amp;#39;)
ORDER BY count(*) DESC&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This groups the entries indicating an error by &lt;code&gt;endpoint&lt;/code&gt; and checks which one has the highest error
count. It does so over the data of the last 90 days. (Here I could not use &lt;code&gt;today&lt;/code&gt; minus 90 days as
the data was older at this point and I was too lazy to re-ingest all the data with up-to-date
timestamps, so I just chose a date somewhere within the date range)&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;div&gt;&lt;!--$--&gt;&lt;div&gt;&lt;p&gt;&lt;em&gt;[chart — see article]&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;&lt;!--/--&gt;&lt;!--$--&gt;&lt;div&gt;Figure 1. Queries per second served by database and use case&lt;/div&gt;&lt;!--/--&gt;&lt;/div&gt;
&lt;p&gt;ClickHouse just blows the other databases out of the water. We &lt;em&gt;are&lt;/em&gt; comparing apples to oranges
here as both SQLite and DuckDB are embedded DBs while ClickHouse follows a client-server model. But
either way, the gains are undeniable.&lt;/p&gt;
&lt;p&gt;And this is with a rather unfavorable data model. I’m really misusing the tracing table here but
ClickHouse is just extremely fast at extracting nested JSON attributes. This is undoubtedly thanks
to the &lt;code&gt;Map&lt;/code&gt; data type.&lt;/p&gt;
&lt;p&gt;The steady performance across all use cases even indicates to me that I’m maybe hitting some other
bottleneck. But as it’s plenty good enough I’ll stop here.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Hosting my side projects the easy way</title><link>https://marending.dev/notes/server-setup/</link><guid isPermaLink="true">https://marending.dev/notes/server-setup/</guid><description>Choosing boring technology</description><pubDate>Mon, 16 Jun 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;17 Jun 2025&lt;/p&gt;&lt;h1&gt;Hosting my side projects the easy way&lt;/h1&gt;&lt;h2&gt;Choosing boring technology&lt;/h2&gt;&lt;p&gt;I’m writing a bunch of software for myself as a hobby. Most of this software requires a server to
function, be it to store data or to serve a web application. I’ve gone through a couple of
iterations on my hosting setup over the years and finally, I’ve arrived at one I’m quite content
with. In this note I will take you down my path of simplification.&lt;/p&gt;
&lt;h2 id=&quot;few-dependencies&quot;&gt;Few dependencies&lt;/h2&gt;
&lt;p&gt;Software like mine has no special requirements, many have built similar software before and many
will after. There are dozens of ways to deploy and host backend servers, databases and static web
applications. Many of those ways try to abstract complexity away from you, pushing it down into an
infrastructure layer. That sounds appealing, but if you give in to the temptation, you’re dependent
on that machinery. I like to avoid that and make sure I control the whole stack. This doesn’t have
to be as hard as cloud vendors make it sound. They have an interest in making you believe you can’t
administer a database by yourself after all.&lt;/p&gt;
&lt;p&gt;To get more specific, I don’t like platforms as a service like &lt;a href=&quot;https://vercel.com/&quot;&gt;Vercel&lt;/a&gt;. They
promise to get you started quickly, but in the process you lose flexibility, independence and your
money. Instead, I’m opting for a virtual private server, a bare linux box if you will. It represents
the foundational unit to run software on. You can spin up a VPS wherever you want, even on your own
hardware if you want, there is no lock-in.&lt;/p&gt;
&lt;p&gt;Further, I’ve largely stopped using auxiliary services to enable deployments, backups or whatever
else you may want to do. Again, there are platforms that promise you a seamless experience, for just
a little bit of your hard-earned money (or data). But I’ve found that for my purposes I can do
without.&lt;/p&gt;
&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;
&lt;p&gt;My setup is entirely dockerized. I run &lt;a href=&quot;https://caddyserver.com/&quot;&gt;Caddy&lt;/a&gt; as my central web server
that fulfils multiple roles: TLS termination, static site serving and reverse-proxying. All my
frontend-only projects are simply bundled and chucked into a folder for Caddy to serve. All my
backends are dockerized and spun up alongside Caddy, which proxies requests appropriately. All my
domains have automatic SSL certificates issued by &lt;a href=&quot;https://letsencrypt.org/&quot;&gt;Let’s Encrypt&lt;/a&gt;, all
managed by Caddy.&lt;/p&gt;
&lt;p&gt;It’s quite simple, really.&lt;/p&gt;
&lt;h2 id=&quot;deployment&quot;&gt;Deployment&lt;/h2&gt;
&lt;p&gt;The only real question is how my software ends up there. Previously, I was manually copying compose
files, &lt;code&gt;.env&lt;/code&gt; files etc. onto the server and &lt;code&gt;ssh&lt;/code&gt;-ing in to issue docker commands. But I’ve found a
better way, enabled by docker contexts.&lt;/p&gt;
&lt;p&gt;Let me bring you up to speed in case you’re unfamiliar. The &lt;code&gt;docker&lt;/code&gt; cli tool we use to interact
with containers is not doing most of the work itself, it’s instead sending commands to the docker
daemon running in the background. Now, with docker contexts we can instruct docker to send commands
not to the local daemon, but instead a remote one, reachable via &lt;code&gt;ssh&lt;/code&gt; for instance.&lt;/p&gt;
&lt;p&gt;So for my purposes, I’ve set up a docker context that points to my VPS via &lt;code&gt;ssh&lt;/code&gt;. This is entirely
seamless, docker will automatically use the configured ssh key. Now, I can bring up this very
website with the following command:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;docker --context arm compose --file docker-compose.prod.yml up -d&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;arm&lt;/code&gt; is the name of the context (the VPS is an arm server), the &lt;code&gt;docker-compose.prod.yml&lt;/code&gt; is a
local file in this repo. Btw. for &lt;a href=&quot;/notes/rust-spa/&quot;&gt;&lt;em&gt;reasons&lt;/em&gt;&lt;/a&gt;, this website is not a naked bundle
of static assets, but instead a Rust application that serves said bundle.&lt;/p&gt;
&lt;p&gt;And the interesting thing here is that if I want to reference secrets from a &lt;code&gt;.env&lt;/code&gt; file to provide
as environment variables to the running container, I can do just that. The local &lt;code&gt;.env&lt;/code&gt; file is read
and passed along in the raw &lt;code&gt;docker&lt;/code&gt; commands sent over the network to the daemon on &lt;code&gt;arm&lt;/code&gt;. This
way, no files have to be manually managed on the server. Neither compose files, nor &lt;code&gt;.env&lt;/code&gt; files.&lt;/p&gt;
&lt;p&gt;This means that I can set up a new remote context, and immediately bring up my applications with a
single command, given that the &lt;code&gt;ssh&lt;/code&gt; target has a docker daemon running. &lt;em&gt;Lovely&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Now you might think I would have a CI/CD pipeline that gets triggered when I push changes to some
branch and execute this command to update the deployment. You would have been correct some time ago,
but not anymore. As outlined above, I want to avoid third-party services as much as possible. So
instead, I’ve started writing shell scripts to streamline the process of locally building docker
images and installing them on the server. Shocking, I know. This is straight out of a 1997 nerd
magazine. But it works perfectly for my use cases. I can utilize the docker layer cache to make
builds blazing fast, I can separate commits logically from units of deployment and it feels good to
have such flexibility.&lt;/p&gt;
&lt;p&gt;Here’s the full deployment script that I invoke every time I redeploy this website:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/env bash
die() { echo &amp;quot;$*&amp;quot; 1&amp;gt;&amp;amp;2 ; exit 1; }

echo -e &amp;quot;Deploying marending.dev to production!&amp;quot;

[ -z &amp;quot;$(git status --porcelain)&amp;quot; ] || die &amp;quot;There are uncommitted changes&amp;quot;

cd service; version=$(cargo metadata --format-version=1 --no-deps | jq &amp;#39;.packages[0].version&amp;#39; | tr -d &amp;#39;&amp;quot;&amp;#39;); cd ..

echo &amp;quot;Latest version: ${version}&amp;quot;

next_version=$(echo ${version} | awk -F. -v OFS=. &amp;#39;{$NF += 1 ; print}&amp;#39;)

read -p &amp;quot;Enter version to be deployed [${next_version}]: &amp;quot; new_version

new_version=${new_version:-${next_version}}

cd service; cargo set-version &amp;quot;${new_version}&amp;quot; || die &amp;quot;Failed to set version in Cargo.toml&amp;quot;; cd ..

docker buildx build -t &amp;quot;ghcr.io/beingflo/marending-dev:${new_version}&amp;quot; . || die &amp;quot;Failed to build docker image&amp;quot;
docker push &amp;quot;ghcr.io/beingflo/marending-dev:${new_version}&amp;quot; || die &amp;quot;Failed to push docker image&amp;quot;

sed -i &amp;#39;&amp;#39; -e &amp;quot;s/image: \&amp;quot;ghcr.io\/beingflo\/marending-dev:${version}\&amp;quot;/image: \&amp;quot;ghcr.io\/beingflo\/marending-dev:${new_version}\&amp;quot;/&amp;quot; ./docker-compose.prod.yml || die &amp;quot;Failed to write new version to docker compose file&amp;quot;

docker --context arm compose --file docker-compose.prod.yml pull || die &amp;quot;Failed to pull new image&amp;quot;
docker --context arm compose --file docker-compose.prod.yml up -d || die &amp;quot;Failed to bring compose up&amp;quot;

git commit -am &amp;quot;Release ${new_version}&amp;quot;
git tag &amp;quot;${new_version}&amp;quot;
git push origin --tags&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some notes: The &lt;code&gt;die&lt;/code&gt; function is just a handy way to exit early. The version handling is querying
the current version from &lt;code&gt;cargo&lt;/code&gt;, then presenting a choice to the user. Either the default of a
patch increment on the current version, or a user-provided version is chosen. This new version is
then written to the &lt;code&gt;Cargo.toml&lt;/code&gt; file and used to tag the resulting image appropriately.&lt;/p&gt;
&lt;p&gt;Then the docker image is built, pushed into Github container registry and subsequently pulled from
the server and spun up. This script took some tinkering, but now I use a version of this in every
one of my projects.&lt;/p&gt;
&lt;p&gt;I could have also avoided my use of a container registry by directly pushing the image to the
server, but as docker registries are a rather standardized commodity at this point I don’t worry
about vendor lock-in too much on this front.&lt;/p&gt;
&lt;p&gt;For static frontend-only applications my deployment script looks significantly simpler:&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/env bash
die() { echo &amp;quot;$*&amp;quot; 1&amp;gt;&amp;amp;2 ; exit 1; }

echo -e &amp;quot;Deploying rest.quest to production!&amp;quot;

[ -z &amp;quot;$(git status --porcelain)&amp;quot; ] || die &amp;quot;There are uncommitted changes&amp;quot;

npm run build || die &amp;quot;Build failed&amp;quot;

docker --context arm cp dist caddy:/srv/rest.quest || die &amp;quot;Failed to copy files to container&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here I’m copying the &lt;code&gt;dist&lt;/code&gt; folder after a &lt;code&gt;npm run build&lt;/code&gt; straight into &lt;code&gt;Caddy&lt;/code&gt;s docker container.&lt;/p&gt;
&lt;p&gt;I also have a separate &lt;code&gt;deployment&lt;/code&gt; repo that serves as the home for all the infrastructure related
files and configs like the Caddy config, compose file and the deployment script itself to roll out a
new config to the running Caddy container.&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This is a rather old-school way of doing things. I would have scoffed at this not too long ago, but
I’ve come to appreciate the simplicity. I understand every part of this setup and can fix any
issues. I can’t say the same about the network layer in a kubernetes cluster for instance.&lt;/p&gt;
&lt;p&gt;Further, this setup is also efficient. I’m building docker images locally on my laptop in seconds,
rather than spinning up a cloud VM somewhere that takes minutes to build from scratch.&lt;/p&gt;
&lt;p&gt;Lastly I want to emphasize that this is very much designed for my personal use in hobby projects. I
would not recommend you use it in a team. There, the imposed rigor of an automatic CI run is
desirable. But since I’m just having fun here, I might as well deviate from “best practices” at
work.&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>Should I shoot JPEG or RAW?</title><link>https://marending.dev/notes/jpeg-raw/</link><guid isPermaLink="true">https://marending.dev/notes/jpeg-raw/</guid><description>Let me overthink this</description><pubDate>Wed, 11 Jun 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;12 Jun 2025&lt;/p&gt;&lt;h1&gt;Should I shoot JPEG or RAW?&lt;/h1&gt;&lt;h2&gt;Let me overthink this&lt;/h2&gt;&lt;p&gt;I’ve been taking pictures on vacation, around the house and out and about with friends for a couple
of years now as a way to document life. But after all this time, I’m still not happy with my photo
workflow. In a departure from my usual technical musings, in this note I’m exploring one of the age
old questions the old philosophers already asked.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Should I shoot JPEG or RAW on this thing?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Plato&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is one of those notes where I’m mostly trying to pelt my thoughts into some cohesive form.
There may or may not be anything to see here for you.&lt;/p&gt;
&lt;h2 id=&quot;some-background-on-fujifilm-image-formats&quot;&gt;Some background on (Fujifilm) image formats&lt;/h2&gt;
&lt;p&gt;I shoot with a Fujifilm X100F. Fuji cameras come with so-called &lt;em&gt;film simulations&lt;/em&gt;, a fancy way to
say you can tweak the JPEG output of your camera. Every other camera manufacturer offers the same,
but some say Fujis JPEGs are nicer. I can only compare to Olympus JPEGs, where every single profile
other than the standard one &lt;em&gt;does&lt;/em&gt; look terrible. Either way, for me that means that no matter how
long I stare at an image in an image editor, I can never get it to look as nice as a Fuji JPEG in
the Classic Chrome look 👌.&lt;/p&gt;
&lt;p&gt;These film simulations are so infamous at this point that there are
&lt;a href=&quot;https://fujixweekly.com/2017/08/27/my-fujifilm-x100f-classic-chrome-film-simulation-recipe/&quot;&gt;entire websites&lt;/a&gt;
dedicated to curating Fuji &lt;em&gt;recipes&lt;/em&gt;, which include the simulation itself as well as other
parameters that can be tweaked.&lt;/p&gt;
&lt;p&gt;Now, when I shoot JPEGs I’m getting these nice colors out of the box. But those very colors (or that
lack thereof in case of a B&amp;amp;W recipe) are baked into the file. It’s hard to recover from that. If I
crushed those shadows as a stylistic choice in camera, I’m not getting them back. That’s what RAWs
are for: These files are more or less raw sensor readings from the camera. Maximally flexible,
maximally boring and flat without any editing applied. Here are some axioms about these files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You can always recreate JPEGs from RAW&lt;/li&gt;
&lt;li&gt;You can never recreate RAWs from JPEGs, or even, say, color JPEGs from B&amp;amp;W JPEGs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It’s a one-way street. As for the first point, you can even get those authentic Fuji JPEGs thanks to
&lt;a href=&quot;https://www.fujifilm-x.com/de-ch/products/software/x-raw-studio/&quot;&gt;X Raw Studio&lt;/a&gt;. This piece of
software allows you to send RAW files through your physical cameras JPEG engine to get the very same
result as if you had shot JPEG in the first place.&lt;/p&gt;
&lt;p&gt;Alternatively of course, you can wrangle RAWs into any grid of pixels physically possible (including
those magical Fuji pixels) via a RAW editor. From this property, the age-old wisdom originates:
Beginners shoot JPEG because they don’t any better, everyone else shoots RAW.&lt;/p&gt;
&lt;h2 id=&quot;where-am-i-coming-from&quot;&gt;Where am I coming from?&lt;/h2&gt;
&lt;p&gt;I currently shoot RAW+JPEG because I like the JPEG colors, but I also like the flexibility and
freedom to stop liking them in the future. But that’s where the trouble starts. Now I have to cull
those images. How do you look through images on your disk that occur in pairs and delete the bad
ones? You can use specialized software that understands that &lt;code&gt;DSCXXX.jpeg&lt;/code&gt; and &lt;code&gt;DSCXXX.raf&lt;/code&gt; are the
same image and should be treated as such: Deletion of the image should remove both files. On macOS,
such software is surprisingly elusive and / or expensive. So, as you do, you write a script for it.&lt;/p&gt;
&lt;pre tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;mkdir ./trash
for file in ./raw/*; do
  [[ $file = *.JPG ]] &amp;amp;&amp;amp; continue  # skip .JPG files
  jpg_file=$(basename ${file%.*}.JPG)
  [[ -s ./jpeg/$jpg_file ]] || mv &amp;quot;$file&amp;quot; ./trash
done&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here’s how this works: When I plug the SD card from my camera into my computer, I get a folder with
images, each a &lt;code&gt;.jpeg&lt;/code&gt; and a &lt;code&gt;.raf&lt;/code&gt; version. I then copy the JPEG files into a &lt;code&gt;jpeg&lt;/code&gt; folder and the
RAW files into a &lt;code&gt;raw&lt;/code&gt; folder. Then I cull the JPEGs with Preview. Once done, I run the above
script, which moves all the RAW files into the trash folder, that don’t have a corresponding JPEG
image.&lt;/p&gt;
&lt;p&gt;This makes the culling process bearable. But now what? I enjoy the JPEGs coming out of my camera (I
spent considerable amount of time researching recipes, they better) so I don’t need to do any heavy
color correcting or such. But I still need to make the occasional exposure adjustment, leveling or
crop. So, after months of deliberation, I purchased a license for
&lt;a href=&quot;https://www.captureone.com/en&quot;&gt;Capture One&lt;/a&gt; for an eye-watering 300 bucks a while back. I import
the JPEGs into CO and do my light adjustments, then I export them at some arbitrary resolution and
quality setting. That’s it, those are my images now that I occasionally look at.&lt;/p&gt;
&lt;p&gt;I’ve started printing my images as well, lately. For this, I also take the original JPEGs and edit
them to get them to look the way I want out of the printer (the damn thing always skews magenta).&lt;/p&gt;
&lt;p&gt;Oh the RAW files, what happened to them? Well they get chucked in a backup somewhere, in case I need
them in the future. I rarely do, but you never know. In an unrelated thought the term ‘data hoarder’
comes to mind.&lt;/p&gt;
&lt;p&gt;This workflow is somewhat convoluted, that’s why I’m reevaluating it. I don’t like that I have to
juggle two file types. I don’t like the indecisiveness of wanting to make decisions in-camera, but
then keeping a backup just in case. I also don’t like that with my (non-destructive) edits on the
JPEGs in Capture One, the “source of truth” is in a proprietary database file of this editor and
&lt;em&gt;not&lt;/em&gt; some standard image files.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Now this right here is the reason I’m writing notes like this. Only when trying to verbalize the
reasons I dislike my workflow did this fugitive thought crystallize. Writing is thinking and all
that jazz.&lt;/p&gt;&lt;/div&gt;
&lt;p&gt;What I don’t like either is that RAW files are not a “progressive” enhancement over my JPEGs, they
are a completely separate starting point: Say I see that my JPEG is badly overexposed, I reach for
my RAW file, which looks completely different. I first need to get it to match the colors of the
JPEG before I even attempt to utilize some of the latitude that this file has. Now that I think
about it, I &lt;em&gt;really&lt;/em&gt; don’t like that the whole Capture One catalogue is &lt;em&gt;the&lt;/em&gt; source of truth for my
creative work when it’s so messy. E.g. when I lightly edit an image as usual for digital usage it
looks very different from the edit for print. These two versions show up in my CO catalogue. This
feels messy, call me old-fashioned but I want to use OS directories to organize my images, not
proprietary, non-transparent, custom behavior of &lt;em&gt;some&lt;/em&gt; piece of software.&lt;/p&gt;
&lt;p&gt;I do have a perpetual license, so I shouldn’t expect to lose access at some point but it still
doesn’t sit right with me. Oh boy, going into this I didn’t want to question my use of Capture One,
I paid good money for it after all. But here we are, let’s see where this goes.&lt;/p&gt;
&lt;h2 id=&quot;what-do-i-like&quot;&gt;What &lt;em&gt;do&lt;/em&gt; I like?&lt;/h2&gt;
&lt;p&gt;First, what do I care about when out and about taking pictures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I want to choose settings in camera&lt;/li&gt;
&lt;li&gt;I want a simple shooting experience (optical viewfinder)&lt;/li&gt;
&lt;li&gt;I don’t want to have to think about post-processing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I’ve shot analog film for a while and learned to love the simplicity of it. This is part of the
reason I enjoy the X100F so much, it offers a hybrid optical / electronic viewfinder as well as a
whole bunch of physical dials for setting exposure. I would love to use the OVF more, I often avoid
it because I worry that I get the wrong exposure in-camera if I’m not previewing it through the EVF.&lt;/p&gt;
&lt;p&gt;I would also like to choose what type of image I’m taking when I take it. I want to decide to shoot
B&amp;amp;W in-camera, I don’t want to sit in front of a computer after the fact for hours, pondering which
image might look nice in black and white.&lt;/p&gt;
&lt;p&gt;Next, what should my workflow look like after I’m done taking pictures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simple. I don’t want to spend much time editing, culling and otherwise managing photos&lt;/li&gt;
&lt;li&gt;Flexible. I want to be able to change my mind later&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now here is an interesting dichotomy, the flexibility here is completely at odds with choosing
settings in-camera above. When taking pictures I don’t want to be babysat by post-processing Me. At
the same time I don’t want to facepalm in front of my computer, looking at hundreds of overexposed
images that some buffoon with a camera took.&lt;/p&gt;
&lt;p&gt;I think the flexibility in post-processing has to win out. I’ve taken wedding pictures before where
a series of bad decisions led to some seriously overexposed pictures. It was very fortunate I had
the RAW files to put through X Raw Studio and get reasonable JPEGs out.&lt;/p&gt;
&lt;h2 id=&quot;what-about-image-quality-anyway&quot;&gt;What about image quality anyway?&lt;/h2&gt;
&lt;p&gt;In a small intermezzo I also want to briefly take a look at how malleable JPEG and RAW files are,
maybe that will also yield some interesting point to consider. Though I’m generally plenty happy
with my JPEGs.&lt;/p&gt;
&lt;p&gt;First, here is a straight-out-of-camera JPEG from a recent trip to Wales. This was run through
Capture One just to compress it for web viewing. Wouldn’t want to bother you with a 15MB file.
&lt;img src=&quot;/_astro/_jpeg-original.B0p-C97O_Z1Jzwey.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt;&lt;/p&gt;
&lt;p&gt;The same image now from the unprocessed RAW file. Note that a RAW file has to be interpreted somehow
to show it on screen. Here, Capture One is used to convert into a JPEG but with no special editing
applied and a linear curve.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_raw-flat.B9htuR5K_7fY3z.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt;&lt;/p&gt;
&lt;p&gt;Needless to say the RAW file lacks a lot of contrast that could obviously be added if desired.&lt;/p&gt;
&lt;p&gt;But what if I wanted to recover some shadow details at the bottom of the image? First a crop of the
JPEG with shadows lifted, then the RAW file:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_jpeg-shadow.-AM5tN9f_1ftzXJ.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt; &lt;img src=&quot;/_astro/_raw-shadow.DwtD9Nuz_jjP2N.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is the flexibility of RAW files. It’s really no competition. In the JPEG the information has
simply been compressed away while the RAW file maintains it.&lt;/p&gt;
&lt;p&gt;Next, let’s look at a black and white image. Here, I have used Fujis ACROS simulation for a nice
look. Below is again the RAW file with a linear curve.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_jpeg-bw.D_IP7D3k_27xK1k.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt; &lt;img src=&quot;/_astro/_raw-bw.CFNGDbxj_1xejvI.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt;&lt;/p&gt;
&lt;p&gt;Zooming in to a section of the image reveals the grain that I configured in my B&amp;amp;W recipe. It just
looks really nice. Yes, I add artificial film grain to my images, I’m that kind of a hipster. Again,
this look could be reproduced from the RAW file, it would just require significant effort that I’m
not willing to spend.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_jpeg-grain.SNiZE0lh_ZmIHB7.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt; &lt;img src=&quot;/_astro/_raw-grain.DzAMzNOX_ultBc.webp&quot; alt=&quot;image&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2048&quot; height=&quot;1365&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;capture-one-tone-curves&quot;&gt;Capture One tone curves&lt;/h2&gt;
&lt;p&gt;But here is an interesting thing I just learned while writing this note: Capture One has tone curves
that mimic Fujis film simulations. Supposedly they even worked with Fujifilm to get them to match
closely. This means I can take RAW files, set the tone curve to &lt;code&gt;Auto&lt;/code&gt; and CO will choose the
appropriate curve as selected in camera. For this, it will read the metadata embedded in the file
and select the appropriate simulation. I could still override it by specifically choosing e.g.
Eterna on an image that was shot on Classic Chrome.&lt;/p&gt;
&lt;p&gt;Looking at those curves through my untrained eyes, it seems they match quite well. There are some
differences like the lack of vignette control in the curve vs Fujis JPEG engine. This is kind of
expected and can easily be fixed for all images that are imported. The problem is that these curves
only reproduce the simulation, not all the custom settings in the camera, like white balance shift,
pushing highlights etc. Alas, you can’t have it all.&lt;/p&gt;
&lt;h2 id=&quot;so-what-are-my-options&quot;&gt;So what are my options?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Shoot JPEG only&lt;/li&gt;
&lt;li&gt;Shoot RAW only&lt;/li&gt;
&lt;li&gt;Shoot RAW+JPEG&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Shooting only JPEG is not an option. It’s too risky not to be able to reprocess images if rough
mistakes happen or my stylistic taste changes.&lt;/p&gt;
&lt;p&gt;Shooting only RAW sees two sub-options in my opinion: Either I plan to shoot RAW and then run them
through X Raw Studio to get JPEGs out, or I run them through Capture One to edit and get JPEGs out.
The first option is kind of pointless, might as well shoot RAW+JPEG then as the editing capabilities
of X RAW Studio are very limited. So RAW -&amp;gt; Capture One -&amp;gt; JPEG is the only viable path for this
option.&lt;/p&gt;
&lt;p&gt;Lastly, shooting RAW and JPEG is where I’m currently at. The only thing I could vary here is using
the RAWs as starting point for editing rather than the JPEGs. But at that point I might as well just
shoot RAW only.&lt;/p&gt;
&lt;p&gt;So this is where it’s at: Continue shooting RAW+JPEG, edit from JPEGs and keep RAWs as backup. Or
shoot RAW only and use it for editing.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Funnily enough, normally when I ponder questions like this I’m secretly just trying to reason my
way into the solution I already deep down prefer. But here I’m genuinely stumped, I don’t know
which way I want to go.&lt;/p&gt;&lt;/div&gt;
&lt;p&gt;Pros RAW only:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single source file&lt;/li&gt;
&lt;li&gt;Full flexibility with editing&lt;/li&gt;
&lt;li&gt;Can be more careless with exposure (OVF)&lt;/li&gt;
&lt;li&gt;Could be more creative in post if desired&lt;/li&gt;
&lt;li&gt;Less disk space required&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cons RAW only:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Always need a RAW editor to get JPEGs&lt;/li&gt;
&lt;li&gt;Reliance on CO to get Fuji-like output&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pros RAW+JPEG:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JPEG readily available and typically good enough&lt;/li&gt;
&lt;li&gt;Don’t strictly need any software, can use JPEG straight away&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cons RAW+JPEG:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Duality of files, RAW really rarely needed&lt;/li&gt;
&lt;li&gt;Editing JPEGs non-destructively just feels icky&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;verdict&quot;&gt;Verdict&lt;/h2&gt;
&lt;p&gt;After trying the RAW workflow for the most recent set of images from my trip to Wales, I’m not
convinced. Editing RAW files is noticeable slower in Capture One than JPEGs (understandably,
considering the size difference). Many images I’ve applied some exposure compensation, white balance
shift etc. to, and all that has to be fixed again in CO. I also can’t get over the fact that I’m
tied to this software for the specific Fuji tone curves.&lt;/p&gt;
&lt;p&gt;I’ve decided to stick to my RAW+JPEG workflow, keeping the RAW around just as an insurance if my
taste changes and I want to reprocess old images to fit my new style. And then I just make minor
adjustments on my Fuji JPEGs, export to the appropriate size and quality and that’s that. I might
experiment with some scripting to simplify the culling and file management process as well as play
around with Capture One settings to see if side-car metadata files feel better to me than some
opaque database somewhere.&lt;/p&gt;
&lt;p&gt;Either way, sorry to string you along for this extensive rambling only to stick to what I’ve been
doing. At least now I can say I’ve considered the alternatives. Now to go out and shoot instead of
overthinking image formats…&lt;/p&gt;&lt;/article&gt;</content:encoded></item><item><title>go: An opinionated bookmark aggregator</title><link>https://marending.dev/notes/go-rest-quest/</link><guid isPermaLink="true">https://marending.dev/notes/go-rest-quest/</guid><description>A project report</description><pubDate>Sat, 07 Jun 2025 22:00:00 GMT</pubDate><content:encoded>&lt;article&gt;&lt;p&gt;08 Jun 2025&lt;/p&gt;&lt;h1&gt;go: An opinionated bookmark aggregator&lt;/h1&gt;&lt;h2&gt;A project report&lt;/h2&gt;&lt;p&gt;At work I tend to juggle a lot of links to different tools, confluence pages and other resources. As
you do, I would maintain as many folders of bookmarks as my bookmark toolbar would allow. Visiting a
link involved rerunning the mental process that led me to save it at its particular place in the
folder hierarchy, then arduously clicking around until I got where I wanted to.&lt;/p&gt;
&lt;p&gt;I figured there had to be a better way, so I set out to build &lt;code&gt;go&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Much of the technical details I will skip here as this application works very similarly to
&lt;a href=&quot;https://rest.quest&quot;&gt;rest.quest&lt;/a&gt;. I would thus recommend to read &lt;a href=&quot;/notes/rest-quest/&quot;&gt;that project
report&lt;/a&gt; first.&lt;/p&gt;&lt;/div&gt;
&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;go&lt;/code&gt; is a tiny web application that shows you a list of your saved links as well as a search bar to
search through your links. For each link, the date of last access as well as the number of accesses
is shown. Entries are ordered by recency of access.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_app1.BIHNkjB__276xCh.webp&quot; alt=&quot;Screenshot of go&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2204&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;Clicking on a link naturally takes you there, so does hitting &lt;code&gt;enter&lt;/code&gt;. &lt;code&gt;cmd+enter&lt;/code&gt; opens the
selected link in a new tab.&lt;/p&gt;
&lt;p&gt;When some search query is entered, the list is filtered down to the matching entries. Both the link
value itself as well as the description are considered in the search. Notably, the search logic
matches each space-separated term separately. In this case this allows filtering down to &lt;code&gt;github&lt;/code&gt;
links, and then within those to the ones containing &lt;code&gt;fl&lt;/code&gt; very quickly.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_app5.DuyFLr2a_HVx1j.webp&quot; alt=&quot;Screenshot of go&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2394&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;Hitting &lt;code&gt;n&lt;/code&gt; opens two input fields that allow adding a new link with description.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_app6.aroNv8jk_9b7hm.webp&quot; alt=&quot;Screenshot of go&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2394&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;Similar to &lt;a href=&quot;https://rest.quest&quot;&gt;rest.quest&lt;/a&gt;, this application is local-first. In fact, you can visit
it &lt;a href=&quot;https://go.rest.quest&quot;&gt;right now&lt;/a&gt; and start using it without any signup. If you do want to
synchronize your bookmarks across devices or have them stored off-device for redundancy, you can
enter credentials for an S3-compatible storage provider in the application. It then syncs its state
to the provided bucket. More details about the inner workings of this can be found
&lt;a href=&quot;/notes/rest-quest/&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_app2.CsxDIglv_Z1OqRdI.webp&quot; alt=&quot;Screenshot of go&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2204&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;Most of my applications also boast a feedback function. I find myself encountering buggy software
with no way to report it to the developers to often to not add a feedback channel to my own
software.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_app3.BxeWAqSq_Z1Pd1vT.webp&quot; alt=&quot;Screenshot of go&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2204&quot; height=&quot;1434&quot;&gt;&lt;/p&gt;
&lt;p&gt;The landing screen of the application also doubles as the help screen, explaining all functions and
how to navigate around. The lack of buttons or nav element is equal parts laziness as well as a
brutalist stylistic choice. The least I can do to help someone get started is add a help screen.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/_astro/_app4.D4-Pcq80_Z2ehcIN.webp&quot; alt=&quot;Screenshot of go&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;4430&quot; height=&quot;2881&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;bookmark-dump-import&quot;&gt;Bookmark dump import&lt;/h3&gt;
&lt;p&gt;Once I was ready to start using this application full time, I needed a quick way to port over my
links from Firefox bookmarks. To this end I built a crude import mechanism that traverses a JSON
dump from Firefox’s bookmark manager and imports all the links into &lt;code&gt;go&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;After this, I’ve never used the feature again, so use at your own risk.&lt;/p&gt;
&lt;h3 id=&quot;browser-keyword-search&quot;&gt;Browser keyword search&lt;/h3&gt;
&lt;p&gt;The absolute &lt;em&gt;killer feature&lt;/em&gt; of this application I didn’t even anticipate. A happy little accident
if you will. As I was working on &lt;code&gt;go&lt;/code&gt;, someone pointed out to me the keyword search feature of
Firefox in an unrelated conversation.&lt;/p&gt;
&lt;p&gt;If you’re unfamiliar: Visit e.g Google Maps, right click in the search bar and select
&lt;code&gt;Add a keyword for this Search&lt;/code&gt;. Now, you can enter e.g. &lt;code&gt;maps&lt;/code&gt; as the keyword. Now you’re all set.
Open a new tab, enter &lt;code&gt;maps zurich&lt;/code&gt; and hit enter: You’re automagically presented with Maps, focused
on Zurich.&lt;/p&gt;
&lt;p&gt;The way this works is very simple. Notice how &lt;code&gt;maps zurich&lt;/code&gt; opens Maps with a &lt;code&gt;?q=zurich&lt;/code&gt; query
param (briefly before it changes the URL). Maps knows to use this query param as a search input.&lt;/p&gt;
&lt;p&gt;Needless to say that this immediately lit up an idea in my head. I can implement the same in &lt;code&gt;go&lt;/code&gt;,
but not only do I pull the search term from the query parameter and pre-filter the results in the
list, I also directly visit the link if only one result is left! In other words, if I search for
&lt;code&gt;gith prof&lt;/code&gt;, the only match is &lt;a href=&quot;https://github.com/beingflo&quot;&gt;https://github.com/beingflo&lt;/a&gt; (due to the description containing
&lt;code&gt;profile&lt;/code&gt;). With the keyword search set up with keyword &lt;code&gt;go&lt;/code&gt;, I can now open a new tab, type
&lt;code&gt;go gith prof&lt;/code&gt;, hit enter and end up directly on the page I wanted to go.&lt;/p&gt;
&lt;p&gt;The joy of using this is hard to overstate. Especially in a work context, this has turned many
frustrating moments of searching for that elusive link into a breeze. I’ve been meticulously adding
any link I think I might need in the future to &lt;code&gt;go&lt;/code&gt; with a good description. Now whenever some
coworker is looking for that &lt;em&gt;release plan&lt;/em&gt; that was presented last week, I’m the one to pull it up
in 3 seconds.&lt;/p&gt;
&lt;p&gt;It feels like everyone else is stuck in the stone age with their bookmark folders.&lt;/p&gt;
&lt;h2 id=&quot;learnings&quot;&gt;Learnings&lt;/h2&gt;
&lt;p&gt;Despite &lt;code&gt;go&lt;/code&gt; being one of my smallest projects, it is one of the most impactful. I use this thing
probably a hundred times every single day, saving me some time and, more importantly, nerves. This
project has encouraged me to look for those tiny inefficiencies in my workflows and try to envision
improvements. It also really goes to show how little effort is needed to massively improve your
personal interaction with &lt;em&gt;whatever&lt;/em&gt;, if you’re willing to build your own software.&lt;/p&gt;
&lt;p&gt;The keyword search gets most of the credit for how cool this project turned out. I couldn’t believe
that I, a self proclaimed power-user of Firefox, missed this feature for over a decade. Now I’ve
been thinking about more ways to leverage it: When I built &lt;a href=&quot;/notes/fieldnotes/&quot;&gt;fieldnotes&lt;/a&gt;, I
didn’t know about it yet, so opening a note I already know the title to involves more clicks than it
should. For &lt;a href=&quot;https://jour.rest.quest&quot;&gt;jour&lt;/a&gt; (project report is in the plans), adding a new journal
entry could also benefit from a keyword. The possibilities are endless.&lt;/p&gt;&lt;/article&gt;</content:encoded></item></channel></rss>