<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Salaheldinaz</title><link>https://salaheldinaz.com/</link><description>OSINT · Intelligence · Cyber Security · Automation · Technologist · Investigator</description><language>en-US</language><lastBuildDate>Thu, 05 Nov 2026 10:47:12 +0000</lastBuildDate><atom:link href="https://salaheldinaz.com/tags/arabic/" rel="self" type="application/rss+xml"/><item><title>Multilingual monitoring at production scale</title><link>https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/</link><guid isPermaLink="true">https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/</guid><pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate><description>Arabic, English and Hebrew, collected and enriched by a worker with a queue per stage. The same 6 stages as everything else in this series, except a shift depends on them being up.</description><category>osint</category><category>automation</category><category>ai</category><category>monitoring</category><category>arabic</category><category>project</category><content:encoded><![CDATA[<span class="hero-label not-prose">The Analyst&#39;s Stack · Showcase</span>

<p>Other people&rsquo;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.</p>
<h2 id="why-i-built-it">Why I built it</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2 id="what-it-solves">What it solves</h2>
<p>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.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-cadence_light.svg" width="968" height="300" alt="A bar chart of how often a source is visited, on one scale from 0 to 60 minutes. An urgent source is visited every 2 minutes, a default source every 15 minutes, and a source quarantined after repeated failures every 60 minutes. A scan tick every 30 seconds decides which sources are due." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-cadence_dark.svg" width="968" height="300" alt="A bar chart of how often a source is visited, on one scale from 0 to 60 minutes. An urgent source is visited every 2 minutes, a default source every 15 minutes, and a source quarantined after repeated failures every 60 minutes. A scan tick every 30 seconds decides which sources are due." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>Failure buys a source less attention, not more.</strong> A quarantined source is visited 30 times less often than an urgent one, so a broken feed cannot crowd out the working ones.</figcaption>
</figure>

<p>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&rsquo;s search reaches the drafts, because
an unapproved draft anyone can find by searching is an unapproved draft that has been
published.</p>
<p><img src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-1-review-queue_hu_f4bb4b0f74193594.webp" width="756" height="998" alt="The list of articles waiting for review, in the dashboard’s Arabic interface and laid out right to left: 12 invented headlines, each row with a checkbox, the source name in Arabic, a priority score from 0.88 down to 0.22, a language code (ar, en or he), an urgent column, and approve, reject and duplicate buttons. Six headlines are in Arabic script, 3 in English, such as “Ferry operator adds evening crossing to Varosh Bay”, and 3 in Hebrew. The top row, language he, is a Hebrew article shown under its Arabic machine-draft headline, with a “machine draft” badge and an urgent tag." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>Where the shift works.</strong> Three scripts in one list, each row carrying the language the
system thinks it is in. The top row is a Hebrew article shown under its Arabic machine
draft, which the Arabic interface prefers whenever one exists. A separate local demo of the
app, seeded with invented articles.</div>

<h2 id="how-it-works">How it works</h2>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-pipeline_light.svg" width="1160" height="190" alt="The 6 series stages, left to right: Collect works out which sources are due every 30 seconds, Normalize extracts then detects the language, Store is Postgres plus a search index, Analyze translates and classifies, Alert sends email, webhook and in-app notifications, Present is a right-to-left dashboard. Normalize is highlighted." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-pipeline_dark.svg" width="1160" height="190" alt="The 6 series stages, left to right: Collect works out which sources are due every 30 seconds, Normalize extracts then detects the language, Store is Postgres plus a search index, Analyze translates and classifies, Alert sends email, webhook and in-app notifications, Present is a right-to-left dashboard. Normalize is highlighted." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The spine, with a queue under each stage.</strong> Same 6 as <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">the foundation post</a> — but here each one is a named queue with its own worker and its own concurrency.</figcaption>
</figure>

<p><strong>Collect.</strong> 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.</p>
<p><strong>Normalize.</strong> 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.</p>
<p><strong>Store.</strong> PostgreSQL for the articles, a search index for retrieval. A translation is a
row with a status, not a column on the article.</p>
<p><strong>Analyze.</strong> Translate, classify, geotag, summarize, score — each its own job.</p>
<p><strong>Alert.</strong> Email out of band, plus webhooks and in-app notifications, all throttled.</p>
<p><strong>Present.</strong> A bilingual dashboard whose default text direction is right-to-left.</p>
<p><img src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-2-translation-review_hu_cdd1c8bae6f04642.webp" width="756" height="1541" alt="The detail page of an invented Hebrew article about a storm near Port Elmar, in the Arabic interface. At the top, the Arabic machine-draft headline with its “machine draft” badge, the original Hebrew headline beneath it, tags for urgent and pending review, and the article’s own approve, reject and mark-as-duplicate buttons. Below, side by side: on the right the original Hebrew text, labelled HE; on the left the Arabic translation open for editing and marked pending review, with a re-translate button, a model picker, the translated title and body, and the translation’s own save, approve and reject buttons." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The gate.</strong> A translation exists, is visible to a reviewer, and is not yet part of the
record. The translation&rsquo;s own approve button sits in the reviewer&rsquo;s editor, open here. A
separate local demo seeded with invented articles; the &ldquo;machine&rdquo; draft was written for the
demo, and no model was called.</div>

<p><img src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-4-search_hu_841e6da923bb6b99.webp" width="756" height="865" alt="An article search for the Arabic word ميناء (port), in the English interface: 6 results. Two Arabic headlines about Port Elmar from The Coastal Bulletin, one pending review and one approved. Two Hebrew headlines: one from The Valley Paper, tagged Urgent and pending review, and one from Port Voice, approved. Two English headlines from Harbour Courier, both approved: “Port Elmar reopens north quay after storm damage survey” and “Fish prices at the Port Elmar market climb for a third week”. Columns show source, language (AR, HE, EN), priority and review status." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>One query, 3 scripts.</strong> An Arabic search reaches an English article through its
approved Arabic translation. Neither English headline contains the word; both match through
approved translations. The search ran as a reviewer, whose searches also reach pending
drafts, which is how the urgent Hebrew row appears. A separate local demo seeded with
invented articles.</div>

<h2 id="how-i-built-it">How I built it</h2>
<p>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.</p>
<p><strong>Every stage is a queue, and that is what makes it restartable.</strong> 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.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-queues_light.svg" width="968" height="240" alt="One article moving left to right through 5 named queues: discover, fetch, extract, enrich, snapshot. The enrich stage is highlighted because its provider returned an error; a loop arrow above it shows the job being re-enqueued after 5 minutes, doubling up to 30, with jitter. The article then carries on to the snapshot queue unharmed." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-queues_dark.svg" width="968" height="240" alt="One article moving left to right through 5 named queues: discover, fetch, extract, enrich, snapshot. The enrich stage is highlighted because its provider returned an error; a loop arrow above it shows the job being re-enqueued after 5 minutes, doubling up to 30, with jitter. The article then carries on to the snapshot queue unharmed." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The other 4 queues never notice.</strong> A provider outage surfaces as one queue&rsquo;s backlog growing, which is also the only place anyone has to look to find it.</figcaption>
</figure>

<p><strong>Right-to-left is a data question, not a CSS question.</strong> I treated it as styling, and
that was wrong. Block direction has to come from the article&rsquo;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 <em>(the rules deciding the visual order of
mixed-direction text)</em> so a Latin source name inside an Arabic sentence does not reorder
the sentence around it.</p>
<p>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.</p>
<p>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 <code>fa</code>, and every
Arabic filter built on the stored language quietly loses it.</p>
<p>What I would do differently is operations. The queue library keeps finished jobs forever
by default and the extraction queue&rsquo;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.</p>
<p>And there is the entity-grouping step that filled a bucket labelled <em>names</em> 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: <span class="link-pending" title="Coming soon in this series">does your AI step actually
work?</span> counts it properly.</p>
<h2 id="a-look-at-the-code">A look at the code</h2>
<p>Language detection, rewritten to stand alone — a margin decides when a fourth language is
believed over the 3 the desk runs on:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">CORE</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;ar&#34;</span><span class="p">,</span> <span class="s2">&#34;en&#34;</span><span class="p">,</span> <span class="s2">&#34;he&#34;</span><span class="p">)</span>   <span class="c1"># the three the desk actually runs on</span>
</span></span><span class="line"><span class="cl"><span class="n">MARGIN</span> <span class="o">=</span> <span class="mf">0.30</span>               <span class="c1"># how much better a fourth language must look</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">pick_language</span><span class="p">(</span><span class="n">scores</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;`scores`: (code, confidence) pairs over every candidate, best first.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">scores</span> <span class="ow">or</span> <span class="n">scores</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>                  <span class="c1"># empty text scores every language zero</span>
</span></span><span class="line"><span class="cl">    <span class="n">top_code</span><span class="p">,</span> <span class="n">top_value</span> <span class="o">=</span> <span class="n">scores</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">top_code</span> <span class="ow">in</span> <span class="n">CORE</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">top_code</span>
</span></span><span class="line"><span class="cl">    <span class="n">best_core_value</span> <span class="o">=</span> <span class="nb">max</span><span class="p">((</span><span class="n">v</span> <span class="k">for</span> <span class="n">c</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">scores</span> <span class="k">if</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">CORE</span><span class="p">),</span> <span class="n">default</span><span class="o">=</span><span class="mf">0.0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">top_value</span> <span class="o">-</span> <span class="n">best_core_value</span> <span class="o">&gt;=</span> <span class="n">MARGIN</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">top_code</span>              <span class="c1"># the evidence is one-sided; record it</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">next</span><span class="p">((</span><span class="n">c</span> <span class="k">for</span> <span class="n">c</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">scores</span> <span class="k">if</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">CORE</span><span class="p">),</span> <span class="kc">None</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">pick_language</span><span class="p">([(</span><span class="s2">&#34;fa&#34;</span><span class="p">,</span> <span class="mf">0.55</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;ar&#34;</span><span class="p">,</span> <span class="mf">0.41</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;en&#34;</span><span class="p">,</span> <span class="mf">0.02</span><span class="p">)])</span>   <span class="c1"># -&gt; &#39;ar&#39;</span>
</span></span><span class="line"><span class="cl"><span class="n">pick_language</span><span class="p">([(</span><span class="s2">&#34;fr&#34;</span><span class="p">,</span> <span class="mf">0.93</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;en&#34;</span><span class="p">,</span> <span class="mf">0.21</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;ar&#34;</span><span class="p">,</span> <span class="mf">0.01</span><span class="p">)])</span>   <span class="c1"># -&gt; &#39;fr&#39;</span>
</span></span><span class="line"><span class="cl"><span class="n">pick_language</span><span class="p">([(</span><span class="s2">&#34;ar&#34;</span><span class="p">,</span> <span class="mf">0.00</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;en&#34;</span><span class="p">,</span> <span class="mf">0.00</span><span class="p">)])</span>                 <span class="c1"># -&gt; None</span>
</span></span></code></pre></div><figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-margin_light.svg" width="968" height="310" alt="Two language-detection results from the code example above, drawn as score bars on a scale from 0 to 1. In the first, Persian scores 0.55 and Arabic, the best of the 3 core languages, scores 0.41; the gap of 0.14 is under the 0.30 margin, so the article is recorded as Arabic. In the second, French scores 0.93 and English, the best core language, scores 0.21; the gap of 0.72 clears the margin, so the article is recorded as French." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-margin_dark.svg" width="968" height="310" alt="Two language-detection results from the code example above, drawn as score bars on a scale from 0 to 1. In the first, Persian scores 0.55 and Arabic, the best of the 3 core languages, scores 0.41; the gap of 0.14 is under the 0.30 margin, so the article is recorded as Arabic. In the second, French scores 0.93 and English, the best core language, scores 0.21; the gap of 0.72 clears the margin, so the article is recorded as French." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The margin, drawn.</strong> The scores are the ones in the example calls above: a close call goes to a core language, and only a one-sided result adds a fourth.</figcaption>
</figure>

<p>And the queue setup — a few lines of configuration, and one of the more consequential
things in the system:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">Queue</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;bullmq&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// The library&#39;s default is &#34;keep every finished job, forever&#34;. Nothing here
</span></span></span><span class="line"><span class="cl"><span class="c1">// ever reads one back, and the extract queue&#39;s payloads carry raw page HTML.
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">retention</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">removeOnComplete</span><span class="o">:</span> <span class="p">{</span> <span class="nx">count</span>: <span class="kt">200</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">removeOnFail</span><span class="o">:</span> <span class="p">{</span> <span class="nx">count</span>: <span class="kt">2000</span> <span class="p">},</span>  <span class="c1">// failures are rarer, smaller, and worth keeping
</span></span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">stages</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;discover&#34;</span><span class="p">,</span> <span class="s2">&#34;fetch&#34;</span><span class="p">,</span> <span class="s2">&#34;extract&#34;</span><span class="p">,</span> <span class="s2">&#34;enrich&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="s2">&#34;snapshot&#34;</span><span class="p">,</span> <span class="s2">&#34;screenshot&#34;</span><span class="p">,</span> <span class="s2">&#34;fullcopy&#34;</span><span class="p">,</span> <span class="s2">&#34;media&#34;</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// `connection` is the Redis host/port object.
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">queues</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Map</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nx">stages</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="nx">s</span><span class="p">,</span> <span class="k">new</span> <span class="nx">Queue</span><span class="p">(</span><span class="nx">s</span><span class="p">,</span> <span class="p">{</span> <span class="nx">connection</span><span class="p">,</span> <span class="nx">defaultJobOptions</span>: <span class="kt">retention</span> <span class="p">})]),</span>
</span></span><span class="line"><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// A stable job id is what makes a re-run safe: ask twice, run once.
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">queues</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;enrich&#34;</span><span class="p">).</span><span class="nx">add</span><span class="p">(</span><span class="s2">&#34;enrich&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">articleId</span> <span class="p">},</span> <span class="p">{</span> <span class="nx">jobId</span><span class="o">:</span> <span class="sb">`enrich-</span><span class="si">${</span><span class="nx">articleId</span><span class="si">}</span><span class="sb">`</span> <span class="p">});</span>
</span></span></code></pre></div><h2 id="what-it-cant-do">What it can&rsquo;t do</h2>
<div class="limitation not-prose"><p class="limitation-title">Detection is a guess, with a deliberate bias</p><div class="limitation-body">A genuinely French or Persian article is recorded as one of the 3 core languages
unless the evidence is decisive. That trade is chosen: a wrong fourth language silently
removes an article from the filters the desk lives in. Text the detector cannot read at
all is stored with no language rather than a guess.</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">Nothing a model produced is evidence</p><div class="limitation-body">Translations, classifications and summaries arrive as drafts and stay drafts until a
person approves them. A rejected translation is dropped from search entirely rather than
filed as a draft, because a refusal is a decision.</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">It is infrastructure, not a tool</p><div class="limitation-body">A database, a cache, a search cluster, an extraction service, an API and a worker. That
is the real cost of the queue, the gate and the 3 languages — and it is why the
one-file watcher earlier in this series is still right for one source.</div>
</div>

<p><img src="https://salaheldinaz.com/blog/multilingual-monitoring-at-production-scale/images/d4-3-queues_hu_d39e677bb7c49615.webp" width="756" height="1201" alt="The Queues panel of the jobs board: 17 named queues, from scan, discover, fetch, extract and enrich down to backup, source-health and llm-batch, each with counts of waiting, active, delayed, completed and failed jobs. fetch has 7 waiting and 3 failed; extract and snapshot have 1 failed each; 8 queues show completed jobs; nothing is active; 9 queues are at zero in every column." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The screen that says it is alive.</strong> A pipeline nobody can see the state of is a
pipeline nobody trusts on a Sunday. One queue has a backlog of 7 and 3 failures; 9 sit
idle. Real queue counts from a separate local demo seeded with invented articles and
placeholder jobs; the 64 completed fetch jobs are among the placeholders, and no page was requested.</div>

<h2 id="where-this-sits-in-the-series">Where this sits in the series</h2>
<p>This is the <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">6-stage spine</a> carrying
weight. Put it beside <a href="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/">monitoring at scale</a>: 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.</p>
<p>Two later posts go deeper into pieces this one only names. <span class="link-pending" title="Coming soon in this series">Does your AI step actually
work?</span> tells the full story of that extraction step, and <span class="link-pending" title="Coming soon in this series">Storing and searching what you
collect</span> covers the schema question underneath all of it.</p>
]]></content:encoded></item></channel></rss>