
The Analyst's Stack Part 14 of 22
Multilingual monitoring at production scale
Other people’s shifts start with this tool, and that single fact is what put a queue under every one of its stages. It collects in 3 languages, enriches what it collects, and holds all of it behind a review gate.
Why I built it
A newsroom verification desk works in more languages than any one person on it reads. This one runs on Arabic, English and Hebrew, and most of the people do 2 of the 3.
The daily failure that produces is not the one people expect. It is not that things go untranslated. It is that the language you do not read becomes the language you stop checking, so a story reaches you through the 2 sources you can skim rather than the 8 that were carrying it.
This system is larger than everything else in this series for one reason: the moment a rota depends on a tool, it acquires obligations a personal script does not have. It has to be restartable. It has to say when it has stopped. It has to be wrong in ways somebody can see.
What it solves
Sources are visited on a schedule instead of by whoever remembers. A tick every 30 seconds works out which are due: 15 minutes by default, 2 for anything urgent, an hour for a source quarantined after repeated failures. A source that keeps breaking gets quieter, not louder.
Everything collected is extracted, language-detected, translated, classified, geotagged and scored — and then it waits. Nothing an AI step produced counts until a person approves it, and the search index knows the difference: approved translations and pending drafts live in separate fields, and only a reviewer’s search reaches the drafts, because an unapproved draft anyone can find by searching is an unapproved draft that has been published.

How it works
Collect. A scan tick decides which sources are due and turns each into a discovery job. Feeds, sitemaps and social sources end up in the same queue.
Normalize. Fetch, strip, extract the body through a small Python service, then answer the question everything downstream depends on: what language is this. That answer is written onto the row.
Store. PostgreSQL for the articles, a search index for retrieval. A translation is a row with a status, not a column on the article.
Analyze. Translate, classify, geotag, summarize, score — each its own job.
Alert. Email out of band, plus webhooks and in-app notifications, all throttled.
Present. A bilingual dashboard whose default text direction is right-to-left.


How I built it
TypeScript on Bun: an HTTP API, a worker process and a dashboard, with PostgreSQL, Redis, a search cluster and a small Python extraction service beside them. The worker is the interesting half — 17 queues, each with its own concurrency limit, from 4 parallel fetchers down to 1 for anything that drives a browser.
Every stage is a queue, and that is what makes it restartable. Job identifiers are derived from the article, so handing a stage the same article twice is deduplicated rather than duplicated, and re-running one stage on one article is a one-line enqueue. When an operator throws the stop switch mid-pipeline, jobs are re-enqueued rather than dropped, with a delay that starts at 5 minutes, doubles up to 30, and carries jitter so 1,000 paused jobs do not wake in the same second. No article is lost, only slowed.
Right-to-left is a data question, not a CSS question. I treated it as styling, and that was wrong. Block direction has to come from the article’s own language, not the interface language — a Hebrew article read by an Arabic-speaking analyst is still a Hebrew article — and mixed text needs per-fragment bidi handling (the rules deciding the visual order of mixed-direction text) so a Latin source name inside an Arabic sentence does not reorder the sentence around it.
What I missed for too long: article bodies carried a direction but no language tag. Roughly a third of the stored corpus is Hebrew, and none of it was announced as Hebrew to the browser. Screen readers read it in the Arabic voice; font fallback resolved against the wrong language. A test now fails if any element declares a direction without a language.
The hard part was language detection failing quietly. The first detector was built from
exactly 3 languages, and a detector can only return a language it was constructed
with — so a French article came back labelled as one of the 3, and no
downstream work could have fixed it, because a fourth language was not observable.
Widening the candidate set to 34 codes creates the mirror problem: Arabic,
Persian and Urdu share a script, so Arabic text can now be answered fa, and every
Arabic filter built on the stored language quietly loses it.
What I would do differently is operations. The queue library keeps finished jobs forever by default and the extraction queue’s payloads carry raw page HTML, so the deployed cache reached 2.74 GB of orphaned job records with every queue sitting empty. And the ladder that pauses AI work on a provider error was fully built while the half that tells a human was not, so a quota storm at three in the morning halted enrichment and waited until someone opened the admin console.
And there is the entity-grouping step that filled a bucket labelled names with things that were not names, for months, while looking fine every time I checked it by eye. That one is worth a post on its own, and it has one: does your AI step actually work? counts it properly.
A look at the code
Language detection, rewritten to stand alone — a margin decides when a fourth language is believed over the 3 the desk runs on:
CORE = ("ar", "en", "he") # the three the desk actually runs on
MARGIN = 0.30 # how much better a fourth language must look
def pick_language(scores):
"""`scores`: (code, confidence) pairs over every candidate, best first."""
if not scores or scores[0][1] <= 0:
return None # empty text scores every language zero
top_code, top_value = scores[0]
if top_code in CORE:
return top_code
best_core_value = max((v for c, v in scores if c in CORE), default=0.0)
if top_value - best_core_value >= MARGIN:
return top_code # the evidence is one-sided; record it
return next((c for c, v in scores if c in CORE), None)
pick_language([("fa", 0.55), ("ar", 0.41), ("en", 0.02)]) # -> 'ar'
pick_language([("fr", 0.93), ("en", 0.21), ("ar", 0.01)]) # -> 'fr'
pick_language([("ar", 0.00), ("en", 0.00)]) # -> None
And the queue setup — a few lines of configuration, and one of the more consequential things in the system:
import { Queue } from "bullmq";
// The library's default is "keep every finished job, forever". Nothing here
// ever reads one back, and the extract queue's payloads carry raw page HTML.
const retention = {
removeOnComplete: { count: 200 },
removeOnFail: { count: 2000 }, // failures are rarer, smaller, and worth keeping
};
const stages = ["discover", "fetch", "extract", "enrich",
"snapshot", "screenshot", "fullcopy", "media"];
// `connection` is the Redis host/port object.
const queues = new Map(
stages.map((s) => [s, new Queue(s, { connection, defaultJobOptions: retention })]),
);
// A stable job id is what makes a re-run safe: ask twice, run once.
await queues.get("enrich").add("enrich", { articleId }, { jobId: `enrich-${articleId}` });
What it can’t do
Detection is a guess, with a deliberate bias
Nothing a model produced is evidence
It is infrastructure, not a tool

Where this sits in the series
This is the 6-stage spine carrying weight. Put it beside monitoring at scale: same shape, same review discipline, one structural difference. There, the queue is a table and one worker drains it. Here, every stage has its own queue and its own way of failing — the price of being the thing a shift starts its morning with.
Two later posts go deeper into pieces this one only names. Does your AI step actually work? tells the full story of that extraction step, and Storing and searching what you collect covers the schema question underneath all of it.