From 826f8383d12ab8446367e0448a9a7c4c31850e53 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Mon, 29 Jun 2026 16:09:46 +0200
Subject: [PATCH 1/2] docs: add ClickHouse analytics blog post
A first-person post on how librechat.ai's analytics run on Plausible backed by ClickHouse, including the recent cloud-to-cloud cluster move.
Adds two reusable blog components: AnalyticsArchitecture, a theme-aware data-flow diagram, and SiteStats, a server component that reads live numbers from the Plausible Stats API when PLAUSIBLE_STATS_API_KEY is set and falls back to a static snapshot otherwise. Includes the cover image and registers both components in mdx-components.
---
components/blog/AnalyticsArchitecture.tsx | 86 +++++++++++++
components/blog/SiteStats.tsx | 116 ++++++++++++++++++
.../blog/2026-06-25_clickhouse-analytics.mdx | 92 ++++++++++++++
lib/mdx-components.tsx | 4 +
.../blog/2026-06-25_clickhouse-analytics.png | Bin 0 -> 115700 bytes
5 files changed, 298 insertions(+)
create mode 100644 components/blog/AnalyticsArchitecture.tsx
create mode 100644 components/blog/SiteStats.tsx
create mode 100644 content/blog/2026-06-25_clickhouse-analytics.mdx
create mode 100644 public/images/blog/2026-06-25_clickhouse-analytics.png
diff --git a/components/blog/AnalyticsArchitecture.tsx b/components/blog/AnalyticsArchitecture.tsx
new file mode 100644
index 000000000..56cccb895
--- /dev/null
+++ b/components/blog/AnalyticsArchitecture.tsx
@@ -0,0 +1,86 @@
+import { Globe, BarChart3, Database, Server, Gauge, ArrowRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import type { ComponentType } from 'react'
+
+/**
+ * Diagram for the ClickHouse analytics blog post: the path a pageview takes from
+ * the browser through Plausible CE into ClickHouse, with PostgreSQL and the
+ * separate Core Web Vitals store shown as satellites rather than in the hot path.
+ *
+ * Server-rendered, theme-aware via the fd-* tokens. The arrows are decorative
+ * (aria-hidden); the node labels carry the meaning, and the whole figure has an
+ * aria-label that reads the flow as one sentence for screen readers.
+ */
+
+function Node({
+ icon: Icon,
+ title,
+ subtitle,
+ muted = false,
+}: {
+ icon: ComponentType<{ className?: string }>
+ title: string
+ subtitle: string
+ muted?: boolean
+}) {
+ return (
+
+ Counted by Plausible, stored in ClickHouse. No cookies, no cross-site tracking.
+
+
+ )
+}
diff --git a/content/blog/2026-06-25_clickhouse-analytics.mdx b/content/blog/2026-06-25_clickhouse-analytics.mdx
new file mode 100644
index 000000000..b8d09435e
--- /dev/null
+++ b/content/blog/2026-06-25_clickhouse-analytics.mdx
@@ -0,0 +1,92 @@
+---
+title: 'ClickHouse behind the scenes: how we count visitors to librechat.ai'
+date: 2026-06-25
+description: "Our website analytics run on Plausible, but the engine underneath is ClickHouse. Here is why we picked it, what it actually does with a couple of million events, and how we moved the whole thing between clusters with about five minutes of downtime."
+tags:
+ - clickhouse
+ - plausible
+ - analytics
+ - self-hosted
+ - privacy
+ - guide
+author: berry
+ogImage: /images/blog/2026-06-25_clickhouse-analytics.png
+category: guide
+---
+
+I run the infrastructure behind LibreChat, including this website. Like anyone who ships a site, I want to know which pages people read, where they come from, and whether a release landed. What I do not want is to follow readers around the internet to find that out, or to greet them with a cookie banner before they have read a sentence.
+
+So librechat.ai runs on [Plausible](https://plausible.io/). It is open source, it counts visits without cookies, and it never builds a profile of anyone. The dashboard gives me the handful of numbers I actually use and nothing I would be uncomfortable collecting. That part is not very interesting to write about, though. The interesting part is what sits underneath it.
+
+
+
+Those numbers are not living in Plausible. Plausible is the app you log into. The data is in ClickHouse, and that is the part worth a blog post.
+
+## Why the storage engine matters here
+
+Analytics is a particular shape of problem. You write a lot of small, similar rows, almost never update them, and then ask broad questions across all of them: how many visits last week, top ten pages this month, which referrer sent the most people. A normal row-oriented database is built for the opposite job, reading and writing whole records one at a time, and it shows the strain once the table gets big.
+
+ClickHouse is a column store. Each column lives together on disk, so a query that only needs `timestamp` and `pathname` reads just those two columns and skips everything else. Similar values sitting next to each other also compress extremely well, which is the detail that surprised me most when I looked at our own data: the main events table holds about **1.9 million rows in roughly 28 MiB on disk.** That is the entire pageview history of the site, and it would fit on a floppy disk twice over. Aggregations that touch all of it come back in well under a second.
+
+That combination, cheap to store and fast to scan, is exactly why Plausible uses ClickHouse as its backend rather than the relational database it uses for everything else.
+
+## The path a pageview takes
+
+When you open a page here, a script of about a kilobyte fires a single request. It carries no cookie and no identifier that follows you to the next site. Plausible Community Edition receives that event, does its session bookkeeping, and writes it into ClickHouse.
+
+
+
+Plausible keeps two kinds of state. The configuration, which sites exist, who can log in, what the settings are, lives in PostgreSQL, because that is a small relational problem and Postgres is the right tool for it. The events and the session rollups, the part that grows forever, live in ClickHouse. Keeping those two stores separate is the whole design, and it is worth holding onto, because it makes the next part possible.
+
+## Moving the whole thing to a new cluster
+
+A few weeks ago I needed to move our analytics off one ClickHouse Cloud cluster and onto another. Same managed service, new cluster, and the goal was the boring one: get every row across with no loss and as little downtime as I could manage.
+
+The tempting approach is to point Plausible at the empty new cluster and let it run its own migrations to build the schema. I did not do that. Plausible's schema is not a handful of plain tables. The events table declares columns that are computed at read time with `dictGet`, looking up things like the human-readable country name from a dictionary:
+
+```sql
+country_name String ALIAS
+ dictGet('plausible_events_db.location_data_dict', 'name', ('country', country_code))
+```
+
+ClickHouse validates those lookups when the table is created, which means the dictionaries, and the tables that feed them, have to exist first. Get the order wrong and the create fails. On top of that, a managed cluster rejects some of the table engines the stock migrations reach for. Re-running migrations on Cloud is a thing you babysit, not a thing you trust.
+
+Both clusters were on the exact same ClickHouse version, so I took the simpler and safer route: copy the schema that already works, byte for byte. I dumped `SHOW CREATE` for every object on the old cluster and replayed it on the new one in dependency order, source tables first, then dictionaries, then everything that refers to them. No translation, no surprises.
+
+Copying the data is a one-liner per table, because ClickHouse can read straight from another cluster:
+
+```sql
+INSERT INTO plausible_events_db.events_v2
+SELECT * FROM remoteSecure(':9440',
+ 'plausible_events_db.events_v2', 'default', '');
+```
+
+The events table has no unique key, so re-inserting a row that is already there would simply duplicate it. The only way to guarantee an exact copy is to make sure the source stops changing first. So the actual cutover was short and dull, which is what you want: stop Plausible, let the old cluster go quiet, truncate and re-copy each table from the now-static source, point Plausible at the new cluster, start it again. Ingestion paused for about five minutes. The website never noticed; the tracker just had nothing to talk to for that window, which costs you a few counts and nothing else.
+
+## Trust the right number
+
+Verifying the copy had one wrinkle worth passing on. Some of Plausible's tables do not store final values. The sessions table is a `VersionedCollapsingMergeTree`: it writes a `+1` row and later a `-1` row to amend a session, and a background process collapses the pairs whenever it gets around to it. So if you compare a raw `count()` between two clusters, the numbers will not match, and that is fine. They are mid-collapse, at different points, on each side.
+
+The number that actually means something is the net:
+
+```sql
+-- raw row counts drift between clusters; the sum of the signs is the truth
+SELECT sum(sign) FROM sessions_v2; -- matched exactly on both
+```
+
+`sum(sign)` was identical on both clusters: same live session count, down to the row. The lesson generalizes. When a table is built to be summed or collapsed, check it the way it is meant to be read, not with a naive `count()`. I confirmed parity that way across every table before I trusted the cutover, and the live numbers started climbing on the new cluster within a couple of minutes of bringing Plausible back up.
+
+## What I would tell you if you are doing this
+
+A few things I would now treat as defaults:
+
+- If the source and target run the same ClickHouse version, clone the working schema instead of re-running migrations. You inherit a schema you already know boots cleanly, and you skip every create-time validation surprise.
+- Freeze writes before the final copy if your table has no key to dedupe on. A short, planned pause buys you an exact copy. Trying to sync a moving table buys you duplicates.
+- Verify each table the way its engine wants to be read. `sum(sign)` for collapsing tables, `sum(value)` for summing ones, `count()` only where a plain count is honest.
+- Keep the analytics events and the app's own configuration in separate stores. It is why moving the heavy half was a contained job and not a rewrite.
+
+None of this is exotic. It is mostly the discipline of letting the database do what it is good at and not fighting it. The numbers at the top of this post are coming straight out of that setup, counted without a single cookie and stored in a database that treats a couple of million events as a rounding error. Privacy and scale are not the trade-off they are sometimes made out to be.
+
+---
+
+_Plausible Community Edition v3.2.0 · ClickHouse 26.2 · librechat.ai. The connection strings, hostnames, and credentials from the actual migration are, of course, not in this post._
diff --git a/lib/mdx-components.tsx b/lib/mdx-components.tsx
index dc0ed6151..7c4e0cbd7 100644
--- a/lib/mdx-components.tsx
+++ b/lib/mdx-components.tsx
@@ -18,6 +18,8 @@ import { TrackedLink, TrackedAnchor } from '@/components/TrackedLink'
import { CredentialsGeneratorMDX } from '@/components/tools/CredentialsGeneratorMDX'
import { YAMLValidatorMDX } from '@/components/tools/YAMLValidatorMDX'
import { CompatibilityMatrix } from '@/components/CompatibilityMatrix'
+import { AnalyticsArchitecture } from '@/components/blog/AnalyticsArchitecture'
+import { SiteStats } from '@/components/blog/SiteStats'
import type { ReactNode } from 'react'
function mapCalloutType(type?: string): 'info' | 'warn' | 'error' {
@@ -256,6 +258,8 @@ export const mdxComponents = {
Accordions,
OptionTable,
CompatibilityMatrix,
+ AnalyticsArchitecture,
+ SiteStats,
Frame,
ThemeImage,
Video,
diff --git a/public/images/blog/2026-06-25_clickhouse-analytics.png b/public/images/blog/2026-06-25_clickhouse-analytics.png
new file mode 100644
index 0000000000000000000000000000000000000000..f6baa2019396a98ba1d2dd86c6ec371c78d58482
GIT binary patch
literal 115700
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