惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - 司徒正美
T
The Blog of Author Tim Ferriss
F
Fortinet All Blogs
Martin Fowler
Martin Fowler
罗磊的独立博客
The GitHub Blog
The GitHub Blog
L
LangChain Blog
A
About on SuperTechFans
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
D
DataBreaches.Net
宝玉的分享
宝玉的分享
U
Unit 42
阮一峰的网络日志
阮一峰的网络日志
Last Week in AI
Last Week in AI
N
Netflix TechBlog - Medium
The Cloudflare Blog
Microsoft Azure Blog
Microsoft Azure Blog
H
Help Net Security
美团技术团队
大猫的无限游戏
大猫的无限游戏
雷峰网
雷峰网
爱范儿
爱范儿
酷 壳 – CoolShell
酷 壳 – CoolShell
MongoDB | Blog
MongoDB | Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
5 gotchas I hit moving LLM logs from Postgres to ClickHouse
SPANLENS · 2026-05-27 · via DEV Community

The problem

I am building Spanlens, an open-source LLM observability platform. Every call to OpenAI, Anthropic, or Gemini gets recorded with its model, latency, tokens, cost, and full request and response body. At low traffic on Supabase Postgres this was fine, but I could already see a few signs that this specific table would not stay fine for long.

  • The requests table will dominate the DB at any meaningful scale. Every other table is bounded by org or project counts, but requests grows with every API call.
  • Date-range scans on created_at are the dashboard's primary query pattern, and Postgres cannot compress the JSON body columns well, so these queries would get slower as the table grew.
  • Daily backup size scales with table size, and backing up large JSON bodies in Postgres looked like a clear path to pain.
  • Aggregation queries like "top expensive prompts in 7 days" that work in milliseconds on a small table are not going to scale that way.

So I migrated to ClickHouse early, before it became a fire. This post is what I wish I had known before the migration, with 5 gotchas that bit me and the fallback design I built so I would not lose data while finding them.

If you want to see the full implementation in context, Spanlens is open source on GitHub under MIT.

Why ClickHouse and not something else

Here is the shortlist I evaluated.

  • TimescaleDB was too close to Postgres and didn't really solve the storage cost concern.
  • DuckDB is great for embedded analytics but I wanted a managed service.
  • BigQuery's pricing model felt unpredictable.
  • ClickHouse Cloud's Development tier was cheap to start with, and columnar compression handles JSON body blobs well.

The decision driver was the workload shape. It is insert-heavy, append-only, and almost all reads are time-range scans with one or two equality filters like organization_id and model. ClickHouse is built for exactly this.

The new architecture

I kept Postgres (Supabase) for everything relational with RLS, including orgs, projects, members, API keys, prompts, alerts, and billing. ClickHouse holds only one table called requests and it is the one that will grow.

Web/Server  -> ClickHouse  (write path, fire-and-forget INSERT)
            |  on failure
            v
            -> Supabase requests_fallback  (durable queue)
            |  every 5 minutes (cron)
            v
            -> replay back into ClickHouse

Enter fullscreen mode Exit fullscreen mode

This is the outbox-ish pattern. I will come back to why.

Reads always go through a single helper that injects scope and retention.

// apps/server/src/lib/requests-query.ts
export async function requestsScope(
  orgId: string,
  opts: { ignoreRetention?: boolean } = {},
) {
  const plan = await getOrgPlan(orgId)            // 'free' | 'pro' | 'team'
  const retentionDays = LOG_RETENTION_DAYS[plan]  // 14 | 90 | 365
  const whereScope = opts.ignoreRetention
    ? 'organization_id = {orgId:UUID}'
    : 'organization_id = {orgId:UUID} ' +
      'AND created_at >= now() - INTERVAL {retentionDays:UInt32} DAY'
  return { whereScope, scopeParams: { orgId, retentionDays }, plan }
}

Enter fullscreen mode Exit fullscreen mode

Direct getClickhouse().query() calls outside this helper are something I avoid. Multi-tenant data leaks are the worst kind of bug, and ClickHouse has no row-level security, so the discipline has to live in the query layer.

Gotcha 1. DateTime64 rejects the Z suffix

JavaScript's new Date().toISOString() returns 2026-05-16T11:49:23.749Z. ClickHouse expects 2026-05-16 11:49:23.749 with a space instead of T and no trailing Z. Insert with the JS default and you get this.

Code: 27. DB::Exception: Cannot parse input: expected " " but got "T"

Enter fullscreen mode Exit fullscreen mode

I added a tiny helper and banned .toISOString() in any code path that writes to ClickHouse.

export function toClickhouseTimestamp(d: Date): string {
  return d.toISOString().replace('T', ' ').replace('Z', '');
}

Enter fullscreen mode Exit fullscreen mode

Reading is the reverse. If you parse a DateTime64 back into a JS Date, you need to put T and Z back.

const date = new Date(row.created_at.replace(' ', 'T') + 'Z');

Enter fullscreen mode Exit fullscreen mode

Gotcha 2. Every number comes back as a string

This one is sneaky because the bug is silent.

ClickHouse's JSONEachRow format returns all numeric columns including Decimal(18, 8), UInt64, and Int32 as JSON strings, not numbers.

{ "cost_usd": "0.00012345", "tokens": "421" }

Enter fullscreen mode Exit fullscreen mode

Then your innocent r.cost_usd + 1 does string concatenation, so "0.00012345" + 1 === "0.000123451". No error. Just wrong.

The fix is mechanical but you have to do it everywhere.

const rows = (await ch.query(...)).map(r => ({
  ...r,
  cost_usd: Number(r.cost_usd ?? 0),
  tokens:   Number(r.tokens ?? 0),
}));

Enter fullscreen mode Exit fullscreen mode

I now treat this as a strict boundary. The helper that wraps ch.query() does the coercion before anything else touches the rows.

Gotcha 3. No ilike, no nulls last

ClickHouse does not have Postgres's ILIKE. If your previous code looked like .ilike('model', '%gpt%'), the direct rewrite is this.

WHERE positionCaseInsensitive(model, 'gpt') > 0

Enter fullscreen mode Exit fullscreen mode

NULLS LAST ordering is a similar story. It has to be explicit instead of implicit.

ORDER BY cost_usd DESC NULLS LAST

Enter fullscreen mode Exit fullscreen mode

Both are easy fixes once you know about them, but they are easy to miss if you rewrite queries in a hurry because they are syntactic differences, not semantic ones. The previous query keeps "working" with no SQL error but silently changes behavior.

Gotcha 4. New column means deploy ordering matters

ClickHouse rejects unknown fields in JSONEachRow inserts by default.

Code: 117. Unknown field 'truncated'

Enter fullscreen mode Exit fullscreen mode

If you add a new column in your INSERT code, ship that code, and your production ClickHouse cluster has not run the migration yet, every insert from that pod fails until the migration lands. Streaming and non-streaming, all of it.

I addressed this with two patterns.

First, run the migration before deploying the code. ALTER TABLE ... ADD COLUMN IF NOT EXISTS lands first, then the code that writes the column ships.

Second, I added a belt-and-suspenders setting.

// lib/clickhouse.ts
const ch = createClient({
  url,
  clickhouse_settings: { input_format_skip_unknown_fields: 1 },
});

Enter fullscreen mode Exit fullscreen mode

This silently skips unknown columns instead of failing the insert. It rescues you from deployment-order mistakes, but it also hides typos. Pair it with a smoke-test that asserts new fields actually make it in.

Gotcha 5. Every failed insert is data loss unless you queue

ClickHouse has good uptime, but "good" isn't 100%. And LLM logs are write-heavy in a way that means every dropped insert is a dollar of cost data you will never get back.

I added a Postgres-backed fallback queue.

export async function logRequestAsync(data: RequestLogData) {
  try {
    await getClickhouse().insert({
      table: 'requests',
      format: 'JSONEachRow',
      values: [data],
    })
  } catch (err) {
    // ClickHouse rejecting or unreachable, queue to durable Postgres backup
    const message = err instanceof Error ? err.message : String(err)
    await supabaseAdmin.from('requests_fallback').insert({
      payload: data,
      organization_id: data.organization_id,
      last_error: message.slice(0, 500),
    })
  }
}

Enter fullscreen mode Exit fullscreen mode

A cron job at /cron/replay-fallback runs every 5 minutes and drains the queue.

export async function replayFallbackQueue() {
  // 1. Drop poisoned rows first (7+ days old or 100+ retries) in one DELETE
  //    so the limited batch budget goes to fresh entries.
  const expiry = new Date(Date.now() - 7 * 86400_000).toISOString()
  await supabaseAdmin.from('requests_fallback')
    .delete()
    .or(`created_at.lt.${expiry},retry_count.gte.100`)

  // 2. Pull the next 50 in FIFO order so a long outage drains in arrival order.
  const { data: rows } = await supabaseAdmin.from('requests_fallback')
    .select('id, payload')
    .order('created_at', { ascending: true })
    .limit(50)

  if (!rows?.length) return

  // 3. One bulk INSERT for the whole batch instead of N round trips.
  //    ClickHouse JSONEachRow accepts arrays trivially.
  try {
    await getClickhouse().insert({
      table: 'requests',
      format: 'JSONEachRow',
      values: rows.map(r => r.payload),
    })
    // 4. Success ??delete the entire batch in one query.
    await supabaseAdmin.from('requests_fallback')
      .delete()
      .in('id', rows.map(r => r.id))
  } catch {
    // Leave them in the queue. Next cron run picks them up and the
    // expiry step above eventually drops them if they stay stuck.
  }
}

Enter fullscreen mode Exit fullscreen mode

Two design choices worth calling out.

I use Postgres for the queue instead of Redis. I already had Postgres for transactional state. Adding Redis just for a recovery queue would be a separate failure domain. Postgres going down is so much worse than ClickHouse going down that pairing them is fine, because if Postgres is also down then the whole product is down and that is a more obvious incident than missing logs.

I do not deduplicate. The requests table has no unique constraint, so a race could insert a row twice. I accepted this trade-off because duplicate logs are a UI cosmetic problem, not a billing problem. If I ever sell on "exactly-once logging" I will redesign this.

Where I am now

It is still early days at Spanlens, so I do not have dramatic before-and-after benchmarks to share. What I can say qualitatively is this.

  • The architecture feels right. Reads are fast, writes are fire-and-forget, and the storage shape no longer fights the workload shape.
  • The fallback queue exists and tests pass, but I have not yet had a real long outage to stress it. I would rather have it and not need it than the other way around.
  • I am no longer worried about the requests table dominating my Postgres backups or query times as Spanlens grows.

The single biggest lesson is to invest in the safety net before you need it.

What I would do differently

A few things in hindsight.

Set input_format_skip_unknown_fields: 1 from day one. It is a small change that buys real resilience against deploy-order mistakes.

Add a synthetic write and read smoke test on every deploy that confirms a known row makes it through. This catches typos that the unknown-field setting now hides.

Make helpers the API boundary loudly. Direct ch.query() calls are the easiest way to introduce a multi-tenant leak. I plan to enforce this with CI lint rules.

Wrapping up

If you are building anything write-heavy with time-range queries on top, whether that is observability, audit logs, event streams, or IoT telemetry, ClickHouse is worth considering early instead of waiting until Postgres becomes a fire. The footguns are real but they all show up early, and the runtime characteristics after that are very pleasant.

Spanlens, the open-source LLM observability platform I built this for, is on GitHub at github.com/spanlens/Spanlens under MIT. The migration helpers quoted here are all under apps/server/src/lib/. If you have done a similar migration or are staring at one, I would love to hear what gotchas hit you in the comments.