<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kanurkar Prateek</title>
    <description>The latest articles on DEV Community by Kanurkar Prateek (@kanurkarprateek).</description>
    <link>https://dev.to/kanurkarprateek</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F959819%2F65f937a7-ab8f-4987-9f24-68da0192ba6a.jpeg</url>
      <title>DEV Community: Kanurkar Prateek</title>
      <link>https://dev.to/kanurkarprateek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kanurkarprateek"/>
    <language>en</language>
    <item>
      <title>Everything I got wrong building an autonomous X bot</title>
      <dc:creator>Kanurkar Prateek</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:39:50 +0000</pubDate>
      <link>https://dev.to/kanurkarprateek/everything-i-got-wrong-building-an-autonomous-x-bot-5hk9</link>
      <guid>https://dev.to/kanurkarprateek/everything-i-got-wrong-building-an-autonomous-x-bot-5hk9</guid>
      <description>&lt;h1&gt;
  
  
  Everything I got wrong building an autonomous X bot
&lt;/h1&gt;

&lt;p&gt;It writes a tech post three times a day and publishes without me. The code took an afternoon. The interesting part was the six times testing proved a design I was confident about was wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Running cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4,054 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bugs found by testing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;BuzzEngine watches Hacker News and GitHub Trending, works out what's actually gaining traction, fetches the primary source, writes a post about it, judges its own work, and publishes to X. On a schedule. Without anyone approving anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;COLLECT ──▶ DEDUPE ──▶ SCORE ──▶ RESEARCH ──▶ DRAFT ──▶ GATE ──▶ PUBLISH
 HN,GitHub   subject +  velocity   fetch the     LLM      LLM +    Buffer
 Reddit, X   URL + text + topic    primary                7 checks  ──▶ X
                                   source                + rules
                                                            │
                                                     fail ──┘
                                                   (try next candidate)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diagram is the boring part. Every box in it works. What follows is the story of the six times a box &lt;em&gt;looked&lt;/em&gt; like it worked and didn't — because that's where all the actual engineering was.&lt;/p&gt;




&lt;h2&gt;
  
  
  01 · The wall you hit before writing any code
&lt;/h2&gt;

&lt;p&gt;X shut down its free API tier in February 2026. Posting now costs &lt;strong&gt;$0.015 a post&lt;/strong&gt; — or &lt;strong&gt;$0.20&lt;/strong&gt; if the post contains a link. No subscription, no monthly minimum; you buy credits and they drain.&lt;/p&gt;

&lt;p&gt;Three posts a day with links is about $18 a month. Not much, but the brief was zero.&lt;/p&gt;

&lt;p&gt;So I proposed putting the link in a self-reply instead: a clean main post at $0.015, link underneath. About 93% cheaper.&lt;/p&gt;

&lt;p&gt;That was wrong, and it took saying it out loud to notice. &lt;strong&gt;A reply is also a post, and it contains a link.&lt;/strong&gt; So it bills at $0.20 too — meaning link-in-reply costs $0.215 per story, &lt;em&gt;more&lt;/em&gt; than putting the link in the main post.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; The unit a price applies to is part of the price. I'd optimised the wrong axis for an hour before checking what "post" meant to the biller.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The only genuinely cheap option was omitting links entirely. Which is a content decision disguised as a billing one, and a bad one for a bot whose job is pointing at things.&lt;/p&gt;




&lt;h2&gt;
  
  
  02 · The way out was someone else's API relationship
&lt;/h2&gt;

&lt;p&gt;The unlock wasn't a cheaper tier. It was noticing that social schedulers hold &lt;em&gt;their own&lt;/em&gt; X API access and absorb that cost as part of their product.&lt;/p&gt;

&lt;p&gt;Most had closed up. Zapier restored its X integration but now requires your own developer credentials. Make.com killed theirs outright in 2025. IFTTT still works but throttles hard on free.&lt;/p&gt;

&lt;p&gt;Buffer's free plan turned out to include the one thing that mattered:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Buffer free plan&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Channels&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queued posts&lt;/td&gt;
&lt;td&gt;10, refillable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API requests / month&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;$0, permanent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three posts a day uses about 90 of those 3,000 requests — 3% of the quota. Buffer talks to X; we never touch X's API, never hold X credentials, never see a bill.&lt;/p&gt;

&lt;p&gt;There was one scare: Buffer stopped accepting new OAuth app registrations, which kills third-party integrations. But the new GraphQL API uses &lt;strong&gt;personal API keys&lt;/strong&gt;, which is exactly what a personal bot needs. No app registration, no approval queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;status &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sent&lt;/span&gt;
&lt;span class="na"&gt;link   &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://x.com/i/status/2086783660578029792&lt;/span&gt;
&lt;span class="na"&gt;error  &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First real post, published through Buffer, $0 spent.&lt;/p&gt;




&lt;h2&gt;
  
  
  03 · Two prompts that couldn't both be satisfied
&lt;/h2&gt;

&lt;p&gt;The first version produced posts like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Persona-based prompt packaging claiming proven deliverables. Shell language suggests text templates, not runtime agents. The value is in the process documentation, not automation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The gate kept rejecting drafts for asserting things the source didn't support. I assumed the writer was being careless. It wasn't.&lt;/p&gt;

&lt;p&gt;I was handing the writer a headline and a one-line blurb, then instructing it to "say something the headline doesn't." There is no honest way to satisfy both. The only moves available are speculation — which the gate then correctly refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompts were in direct contradiction and I'd written both.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was a step I'd left out: fetch the actual primary source before writing. A repo's README, an article's body. Suddenly there was real material to be interesting &lt;em&gt;about&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before research:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Persona-based prompt packaging claiming proven deliverables…&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;After research:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Docker Sandboxes wrap agents in microVMs so you can safely run &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;. Everyone reads this as security; it's really about removing the approval bottleneck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; When a model keeps failing a check, suspect the brief before the model. Two instructions that can't both hold will produce garbage forever.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  04 · The judge was reading a redacted copy
&lt;/h2&gt;

&lt;p&gt;Then the gate started rejecting &lt;em&gt;accurate&lt;/em&gt; posts. It flagged "858 stars" as an unsupported claim — a number sitting right there in the source material.&lt;/p&gt;

&lt;p&gt;Because I'd built the two prompts separately, and the gate's version of the material was missing the engagement figures the writer had been given.&lt;/p&gt;

&lt;p&gt;The gate was doing its job perfectly. It was judging against a strictly smaller context than the writer had, so genuinely-sourced facts looked invented. Three candidates rejected in a row; the run produced nothing and logged no error.&lt;/p&gt;

&lt;p&gt;Both stages now build their material from one shared function. Not because it's tidier — because it makes the mismatch &lt;em&gt;structurally impossible&lt;/em&gt; rather than merely unlikely.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; If one component checks another's work, they must see identical inputs. A verifier with less context than the thing it verifies invents failures.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  05 · Binning good work over four characters
&lt;/h2&gt;

&lt;p&gt;A run rejected all three candidates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gate rejected  too long: 326/280
gate rejected  too long: 284/280
gate rejected  too long: 307/280
run complete — outcome: skipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of those missed by &lt;strong&gt;four characters&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Length was in the same bucket as fabrication: hard fail, discard, move on. But "slightly too long" is a &lt;em&gt;mechanical&lt;/em&gt; problem — exactly what the editor's revision path exists for.&lt;/p&gt;

&lt;p&gt;Rules are now split by whether a rewrite can fix them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fatal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Invented facts, duplicates, blocked terms&lt;/td&gt;
&lt;td&gt;Reject before spending a model call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fixable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Too long, stray hashtag, wall of prose&lt;/td&gt;
&lt;td&gt;Send back to the editor, then re-check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The next run showed it working: &lt;em&gt;"33 characters too long"&lt;/em&gt; → the editor cut hedges, kept the argument, and the re-check passed. That post published.&lt;/p&gt;




&lt;h2&gt;
  
  
  06 · HTTP 200, and nothing inside it
&lt;/h2&gt;

&lt;p&gt;The bot runs on Kimi K2.5 through Azure. Early on, calls came back successful and empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;HTTP &lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;
&lt;span class="na"&gt;content          &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="na"&gt;reasoning_content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;wants&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;me&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;say&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;OK.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;This&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;very&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sim…"&lt;/span&gt;
&lt;span class="na"&gt;finish_reason    &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;length&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasoning models spend the token budget on an internal scratchpad &lt;em&gt;before&lt;/em&gt; writing anything. Run out mid-thought and you get a valid 200 with an empty answer. One request produced &lt;strong&gt;21,568 characters of reasoning&lt;/strong&gt; and no output.&lt;/p&gt;

&lt;p&gt;Three layers up, that surfaced as &lt;code&gt;no JSON value found in model output&lt;/code&gt; — an error pointing nowhere near the cause.&lt;/p&gt;

&lt;p&gt;The adapter now detects the exact shape (empty content + &lt;code&gt;finish_reason: length&lt;/code&gt;) and says what actually happened, naming the fix. The guard fired on its very first real run, which is the only reason I found the next bug.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; A successful status code is not a successful outcome. Check the shape of what came back, and make the error message name the cause, not the symptom.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  07 · A URL is not a subject
&lt;/h2&gt;

&lt;p&gt;The account posted about the same repository twice.&lt;/p&gt;

&lt;p&gt;Dedupe existed — canonical URL matching, fuzzy title similarity, a 45-day memory. It just answers a narrower question than the one that matters. Different repo from the same lab? Passes. Second Docker announcement that week? Passes. Same story from a different outlet? Passes.&lt;/p&gt;

&lt;p&gt;I tried fixing it with judgement first: show the editor the last ten posts, tell it to reject repetition. Then I replayed a near-duplicate against real history.&lt;/p&gt;

&lt;p&gt;It approved. Scores of 7–9 across the board, flagging nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repetition lost to seven positive criteria.&lt;/strong&gt; One instruction among many is not a constraint.&lt;/p&gt;

&lt;p&gt;So candidates now carry subject keys, and anything matching one used in the last seven days is dropped before a model ever sees it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;github.com/labX/agent          ──▶  repo:labx/agent
                               ──▶  org:labx

docker.com/blog/thing          ──▶  site:docker.com

news.ycombinator.com  ──▶  arstechnica.com/x  ──▶  site:arstechnica.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtlety is granularity. Keying on the domain would make &lt;code&gt;github.com&lt;/code&gt; a subject and block every repository on earth. Aggregators — GitHub, HN, Reddit, X — are containers, not subjects, so they resolve to the &lt;em&gt;linked&lt;/em&gt; article's domain instead.&lt;/p&gt;

&lt;p&gt;Verified against the real failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ vercel/ai-sdk                    kept — genuinely new subject
🚫 PrimeIntellect-ai/prime-agent    already posted
🚫 PrimeIntellect-ai/other-thing    different repo, same org
🚫 Docker ships another feature     different story, same company
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Deterministic rules don't drift, don't get talked round, and cost nothing to run. Use judgement for taste; use code for anything you can define.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  08 · Attaching an image can be a downgrade
&lt;/h2&gt;

&lt;p&gt;Images seemed like a straightforward win. They aren't, for a reason specific to the platform: &lt;strong&gt;X gives attached media precedence over the link card.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a post carries a link, X already renders the page's og:image — with the headline and domain attached, for free. Upload that same image and you &lt;em&gt;replace&lt;/em&gt; that card with a bare picture. Strictly worse.&lt;/p&gt;

&lt;p&gt;So the rule became: only attach an image the card wouldn't already show.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Attached&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub repo&lt;/td&gt;
&lt;td&gt;nothing — the card already shows it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product page with a screenshot&lt;/td&gt;
&lt;td&gt;the screenshot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then the first test attached &lt;code&gt;gray.png&lt;/code&gt;. Body images arrive in document order, and page furniture comes first — logos, banners, background textures. They're now filtered and ranked, using dimensions in the filename as the signal: a 2320×1205 screenshot is content, a 1110×326 strip is decoration.&lt;/p&gt;




&lt;h2&gt;
  
  
  09 · What "quality bar" actually meant
&lt;/h2&gt;

&lt;p&gt;The last change came from a question I didn't have a good answer to at first: &lt;em&gt;why not just post whatever it finds?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I read back every rejection. Four of five were invented facts — benchmark scores that don't exist, a timeframe the source never gave, a draft saying &lt;em&gt;"We built"&lt;/em&gt; about someone else's repository. One was mechanical.&lt;/p&gt;

&lt;p&gt;None were "not funny enough." The gate had never been fussy about taste — but the &lt;em&gt;threshold&lt;/em&gt; was, because a single number governed all seven criteria. An honest, unremarkable post got binned for scoring 6 on humour.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Floor&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Publishes under a real name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Funny, interesting, memorable, human, clear, concise&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A flat post costs nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two bars, not one. A merely-decent post publishes. A fabrication never does — and never trades against "we need something today."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; "Quality" isn't one number. Separate the failures that cost something from the ones that are just disappointing.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  10 · What it writes now
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Docker built a cage so AI agents can run 'YOLO mode' safely. That's the actual name for &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your bot gets root on a fake computer where it can delete files freely. We gave robots autonomy, then immediately grounded them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Published · understandable 9 · accurate 9 · human 8&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Someone built a staffing agency for AI coding tools. Hire a frontend wizard or Reddit ninja through an app. Each agent has a personality and KPIs.&lt;/p&gt;

&lt;p&gt;We finally automated the jobs, then immediately recreated the corporate structure to manage them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Published · found on GitHub Trending&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The formatting matters more than it looks. Early drafts came out as one block of prose, which reads as machine-written regardless of how good the observation is. Posts are now 2–4 short beats separated by blank lines — enforced in code, because the break before the last line is what makes a punchline land.&lt;/p&gt;




&lt;h2&gt;
  
  
  11 · The throughline
&lt;/h2&gt;

&lt;p&gt;Six bugs, and five of them share a shape: &lt;strong&gt;a design that was reasonable in the abstract and wrong against reality.&lt;/strong&gt; None were caught by reading the code. All were caught by running it and looking hard at the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd carry to the next one:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test the boring thing first.&lt;/strong&gt; The pricing model, the response shape, the identifier you invented. That's where the assumptions hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer code to prompts for anything definable.&lt;/strong&gt; Dedupe, length, banned phrases, structure — all started as instructions and all worked properly only once they became rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A verifier needs the verified's full context.&lt;/strong&gt; Otherwise it manufactures failures and you debug the wrong component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed on the thing that costs something.&lt;/strong&gt; If the gate errors, the post is rejected. Silence is cheap; a fabrication under your name isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An error message should name the cause.&lt;/strong&gt; "No JSON found" sent me three layers from the actual problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bot has been running since. It publishes up to three times a day, skips days when there's nothing worth saying, and hasn't yet posted anything I'd want to delete.&lt;/p&gt;

&lt;p&gt;That last part is the gate doing its job — which mostly means refusing to publish things a more eager system happily would.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; TypeScript with zero runtime dependencies · GitHub Actions · Buffer free tier&lt;br&gt;
&lt;strong&gt;Sources:&lt;/strong&gt; Hacker News, GitHub Trending, Reddit, X&lt;br&gt;
&lt;strong&gt;Model:&lt;/strong&gt; provider-agnostic — Anthropic, OpenAI, Gemini, or any OpenAI-compatible endpoint&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>automation</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I replayed 5 years of git history into SigNoz. It silently drops any span older than 15 days.</title>
      <dc:creator>Kanurkar Prateek</dc:creator>
      <pubDate>Wed, 15 Jul 2026 11:58:08 +0000</pubDate>
      <link>https://dev.to/kanurkarprateek/i-replayed-5-years-of-git-history-into-signoz-it-silently-drops-any-span-older-than-15-days-57hb</link>
      <guid>https://dev.to/kanurkarprateek/i-replayed-5-years-of-git-history-into-signoz-it-silently-drops-any-span-older-than-15-days-57hb</guid>
      <description>&lt;p&gt;The plan was simple: turn every pull request in&lt;br&gt;
&lt;a href="https://github.com/SigNoz/signoz" rel="noopener noreferrer"&gt;SigNoz's own repo&lt;/a&gt; into a trace, so I could&lt;br&gt;
ask "how long do PRs really wait for review?" with a p95 instead of a vibe.&lt;/p&gt;

&lt;p&gt;Then I sent seven spans. All seven came back &lt;code&gt;200 partialSuccess:{}&lt;/code&gt;. Four of&lt;br&gt;
them existed. Nothing logged an error. No metric moved.&lt;/p&gt;

&lt;p&gt;This is the three hours I spent finding out why, the one-line change that fixed&lt;br&gt;
it, and the bot that made my first dashboard a lie. If you have ever wondered&lt;br&gt;
whether your spans are actually arriving: go look. Don't trust the 200.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Self-hosted SigNoz v0.131.0 on AKS, collector v0.144.5. Pull the PR history from&lt;br&gt;
GitHub's GraphQL API, turn each PR into a trace, push it over OTLP with real&lt;br&gt;
historical timestamps.&lt;/p&gt;

&lt;p&gt;The span model is the whole trick. Every span's &lt;em&gt;duration&lt;/em&gt; is a real wait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR #123 (first commit → merge)
├── authoring              first commit → PR opened
├── awaiting first review  opened → first review     ← the human latency
├── review round 1 by alice (CHANGES_REQUESTED)
└── awaiting merge         last approval → merged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the durations are real, SigNoz's built-in aggregations do the analysis&lt;br&gt;
for free. I didn't have to build any of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gotcha #1: the endpoint that lies
&lt;/h2&gt;

&lt;p&gt;My SigNoz LoadBalancer has a public IP, so I pointed the exporter at it. &lt;code&gt;200&lt;br&gt;
OK&lt;/code&gt;. Zero traces.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;200&lt;/code&gt; is the React app. SigNoz's UI serves &lt;code&gt;index.html&lt;/code&gt; for any unmatched&lt;br&gt;
path, so &lt;code&gt;/v1/traces&lt;/code&gt; returns a cheerful &lt;code&gt;200 text/html&lt;/code&gt;. So does&lt;br&gt;
&lt;code&gt;/this/is/not/real/xyz123&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{content_type}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://&amp;lt;lb-ip&amp;gt;:8080/v1/traces
200 text/html&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;charset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;utf-8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An exporter pointed there gets a success response for every batch, forever,&lt;br&gt;
while dropping every span. The real ingest ports (4317/4318) live on&lt;br&gt;
&lt;code&gt;signoz-otel-collector&lt;/code&gt;, which is &lt;code&gt;ClusterIP&lt;/code&gt; and isn't exposed at all.&lt;br&gt;
&lt;strong&gt;Check the content-type, not the status code.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Gotcha #2: the wall at exactly 15 days
&lt;/h2&gt;

&lt;p&gt;Port-forwarding to the real collector, I sent probes at different ages and&lt;br&gt;
checked what survived:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;span age&lt;/th&gt;
&lt;th&gt;HTTP response&lt;/th&gt;
&lt;th&gt;in ClickHouse?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1d, 5d, 14d, 14.9d&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200 partialSuccess:{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15.1d, 16d, 20d&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200 partialSuccess:{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A hard wall at exactly 15.0 days. The same 200 on both sides of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The wrong theory (that fit perfectly)
&lt;/h2&gt;

&lt;p&gt;SigNoz's traces table has a TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;TTL&lt;/span&gt; &lt;span class="n"&gt;toDateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;toIntervalSecond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1296000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1296000 seconds is 15 days. It's evaluated against &lt;em&gt;the span's own timestamp&lt;/em&gt;&lt;br&gt;
rather than ingest time, so a 2021 span is expired the moment it lands. The&lt;br&gt;
numbers matched exactly. Case closed.&lt;/p&gt;

&lt;p&gt;It was wrong. I raised retention to six years and confirmed it in ClickHouse&lt;br&gt;
(&lt;code&gt;toIntervalSecond(189216000)&lt;/code&gt;). The wall didn't move. Still exactly 15 days.&lt;/p&gt;

&lt;p&gt;Two numbers were both 15 for unrelated reasons and I'd assumed causation. What&lt;br&gt;
killed the theory was bisecting the pipeline: writing a 730-day-old row&lt;br&gt;
directly into ClickHouse, bypassing the collector.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;signoz_traces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signoz_index_v3&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration_nano&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`resource_string_service$$name`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;730&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'aa00...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1122...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'direct-730d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'direct-test'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It persisted. ClickHouse was happy to store a two-year-old span. The database&lt;br&gt;
was never the problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  The actual answer
&lt;/h2&gt;

&lt;p&gt;The collector logged nothing. Its &lt;code&gt;refused&lt;/code&gt;/&lt;code&gt;dropped&lt;/code&gt; metrics were zero. The&lt;br&gt;
pipeline had no filter processor. OpAMP reported &lt;code&gt;"Config has not changed"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The answer was in the source —&lt;br&gt;
&lt;a href="https://github.com/SigNoz/signoz-otel-collector/blob/v0.144.5/exporter/clickhousetracesexporter/clickhouse_exporter.go#L144" rel="noopener noreferrer"&gt;&lt;code&gt;clickhouse_exporter.go:144&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;maxAllowedDataAgeDays&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hardcoded. And in&lt;br&gt;
&lt;a href="https://github.com/SigNoz/signoz-otel-collector/blob/v0.144.5/exporter/clickhousetracesexporter/clickhouse_exporter_v3.go#L412-L429" rel="noopener noreferrer"&gt;&lt;code&gt;clickhouse_exporter_v3.go:414&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;uint64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartTimestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="kt"&gt;uint64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldestAllowedTs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"skipping span outside allowed time window"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this invisible. It logs at &lt;code&gt;Debug&lt;/code&gt;, and the collector runs at&lt;br&gt;
&lt;code&gt;Info&lt;/code&gt;. And it's a bare &lt;code&gt;continue&lt;/code&gt;, so no metric increments and&lt;br&gt;
&lt;code&gt;send_failed_spans&lt;/code&gt; stays at zero, because as far as the exporter is concerned&lt;br&gt;
nothing failed. The span dies in the one gap where nothing is counted: after&lt;br&gt;
accepted, before written.&lt;/p&gt;

&lt;p&gt;Then I found the part that makes this more than a gotcha. The &lt;strong&gt;logs&lt;/strong&gt; exporter&lt;br&gt;
has the identical constant, but&lt;br&gt;
&lt;a href="https://github.com/SigNoz/signoz-otel-collector/blob/v0.144.5/exporter/clickhouselogsexporter/config.go#L55" rel="noopener noreferrer"&gt;exposes it&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;defaultMaxAllowedDataAgeDays&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
&lt;span class="n"&gt;MaxAllowedDataAgeDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="s"&gt;`mapstructure:"max_allowed_data_age_days"`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can backfill logs older than 15 days. You cannot backfill traces. Same&lt;br&gt;
constant, same purpose, one configurable and one not.&lt;/p&gt;

&lt;p&gt;That asymmetry is an unfinished job, and the history says so. In January 2026&lt;br&gt;
someone filed&lt;br&gt;
&lt;a href="https://github.com/SigNoz/signoz-otel-collector/issues/750" rel="noopener noreferrer"&gt;#750&lt;/a&gt; describing&lt;br&gt;
&lt;em&gt;exactly&lt;/em&gt; this, for logs. Their words: "Collector metrics indicate success, but&lt;br&gt;
no data appears in SigNoz/ClickHouse." They found it the same way I did, by&lt;br&gt;
turning on debug logging and reading the source.&lt;/p&gt;

&lt;p&gt;It was fixed the next day.&lt;br&gt;
&lt;a href="https://github.com/SigNoz/signoz-otel-collector/pull/746" rel="noopener noreferrer"&gt;PR #746&lt;/a&gt; added&lt;br&gt;
&lt;code&gt;max_allowed_data_age_days&lt;/code&gt; in &lt;strong&gt;+20/−4 across six files&lt;/strong&gt;. Every one of them&lt;br&gt;
sat under &lt;code&gt;clickhouselogsexporter/&lt;/code&gt;. The traces exporter, with the same hardcoded&lt;br&gt;
15, wasn't touched.&lt;/p&gt;

&lt;p&gt;So the maintainers already agree the limit should be configurable. They merged&lt;br&gt;
that position six months ago. Traces just never got the same twenty lines. As of&lt;br&gt;
v0.144.6, released the day before I went looking, it still hasn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix is one line
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- maxAllowedDataAgeDays: 15,
&lt;/span&gt;&lt;span class="gi"&gt;+ maxAllowedDataAgeDays: 3650,
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I built the patched collector and ran it on my laptop against ClickHouse over a&lt;br&gt;
port-forward, so I never touched the cluster. Then I ran both binaries against&lt;br&gt;
the same database with the same span:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;collector&lt;/th&gt;
&lt;th&gt;HTTP response&lt;/th&gt;
&lt;th&gt;spans stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stock (&lt;code&gt;15&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200 partialSuccess:{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;patched (&lt;code&gt;3650&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200 partialSuccess:{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One constant. With Debug on, the stock binary finally admits it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug  clickhousetracesexporter/clickhouse_exporter_v3.go:414
       skipping span outside allowed time window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Act two: it stored my data eight times
&lt;/h2&gt;

&lt;p&gt;Patch working, I fired all 91,536 spans. The client was delighted: &lt;code&gt;sent&lt;br&gt;
91536/91536 http=200&lt;/code&gt;. The database had &lt;strong&gt;445,038 rows and 52,212 distinct span&lt;br&gt;
IDs&lt;/strong&gt; — 8.1× duplication.&lt;/p&gt;

&lt;p&gt;Writing through a &lt;code&gt;kubectl port-forward&lt;/code&gt; is far slower than the in-cluster path&lt;br&gt;
the default timeout assumes. Writes timed out and the exporter did what a good&lt;br&gt;
exporter does: it retried. Retries are at-least-once, so every timed-out batch&lt;br&gt;
rewrote rows that had already landed. The fix was a 180s timeout and turning&lt;br&gt;
retry &lt;strong&gt;off&lt;/strong&gt;, so a timeout becomes a visible gap instead of silent duplication.&lt;/p&gt;

&lt;p&gt;My own &lt;code&gt;verify&lt;/code&gt; command had called this "all spans accounted for", because it&lt;br&gt;
compared row count to expected instead of distinct span IDs. My verification&lt;br&gt;
tool had the same bug as the thing it was verifying: it trusted a number without&lt;br&gt;
checking what it meant.&lt;/p&gt;
&lt;h2&gt;
  
  
  The payoff, and the bot
&lt;/h2&gt;

&lt;p&gt;With the patch in, all five and a half years landed: &lt;strong&gt;91,532 of 91,536 spans,&lt;br&gt;
7,345 PRs, 2021-01-07 → 2026-07-15&lt;/strong&gt;, for about 5 MB. The data was never the&lt;br&gt;
expensive part — the 15 days was.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7v5s6o6irjcad281vqkx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7v5s6o6irjcad281vqkx.png" alt="Five and a half years of a project's life, reconstructed from git. PR volume grew roughly 10× from 2021 to 2025; 78% merge, 18% are closed unmerged, and once a PR is approved it still takes a p95 of 1.77 days to actually merge."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I opened one trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR #9415  "fix(api-monitoring): border being hidden"     145 d
├── awaiting first review                              144.9 d
├── review round 1 by H4ad      APPROVED                    0 m
├── review round 2 by YounixM   APPROVED                  3.8 m
└── awaiting merge                                        1.5 h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The review took four minutes. The wait took five months.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa16adi8fzoz3dahhsac9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa16adi8fzoz3dahhsac9.png" alt="The whole trace is one bar.  raw `awaiting first review` endraw  runs 20.70 weeks inside a 20.71-week PR; the three commits, two approvals and the merge are the slivers on the right."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My first headline was "the median PR is reviewed in 1 hour." Real number, real&lt;br&gt;
query, complete nonsense. &lt;code&gt;ellipsis-dev&lt;/code&gt; is an AI review bot, the single largest&lt;br&gt;
reviewer in the repo, and it responds instantly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;reviewer&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bots&lt;/td&gt;
&lt;td&gt;1,576&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.85 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;32.8h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;humans&lt;/td&gt;
&lt;td&gt;4,667&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.76 h&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.33 days&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A quarter of the "reviews" weren't people. If you're a human waiting on review,&lt;br&gt;
1.0h is a lie, and it's the number the obvious query hands you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbuvt0idpi4u8ghn9x3w6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbuvt0idpi4u8ghn9x3w6.png" alt="Same question, split by who answered it. The human median is 4.76 hours; the bot's is 1.85 minutes. Blend them and you get the 1.0h that means nothing."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also nearly shipped a "slowest reviewer" leaderboard. The top entries had&lt;br&gt;
&lt;code&gt;n=1&lt;/code&gt;, &lt;code&gt;n=2&lt;/code&gt;, &lt;code&gt;n=3&lt;/code&gt;. A p95 over one sample isn't a statistic, and these are real&lt;br&gt;
named people. Worse, the metric is backwards: &lt;code&gt;awaiting first review&lt;/code&gt; grouped by&lt;br&gt;
reviewer measures how long the PR waited &lt;em&gt;before that person showed up&lt;/em&gt;. They&lt;br&gt;
didn't cause the wait, they ended it. Whoever finally reviewed that 145-day-old&lt;br&gt;
PR rescued it. The leaderboard ranks rescuers as culprits.&lt;/p&gt;

&lt;p&gt;Review &lt;em&gt;volume&lt;/em&gt; is answerable, because it measures work actually done, so&lt;br&gt;
that's what the panel shows instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwkqbi7sjubsz6vo8vwz3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwkqbi7sjubsz6vo8vwz3.png" alt="What the data can honestly say: 17.92% of PRs are abandoned, 190 merged with no review at all, and review volume by person, not speed."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;200 OK&lt;/code&gt; from an OTLP endpoint means "I parsed your protobuf."&lt;/strong&gt; Nothing
more. The only way to know your telemetry arrived is to query it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two equal numbers are not causation.&lt;/strong&gt; The TTL was 15 days and the wall was
15 days and they were unrelated. Bisecting the pipeline took ten minutes and
killed a theory I'd have defended for another hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent drops hide at Debug.&lt;/strong&gt; If data vanishes with no errors, turn on debug
logging before you read any more source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry is at-least-once.&lt;/strong&gt; A slow sink doesn't just make things slow, it makes
duplicates. Prefer a visible gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at who is in your data before you trust an aggregate.&lt;/strong&gt; A quarter of my
reviewers were a bot. The p50 wasn't wrong. It was answering a different
question than the one I asked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Backfilling historical traces into SigNoz is impossible out of the box, and&lt;br&gt;
nothing tells you: not the HTTP response, not the logs, not the metrics. It's&lt;br&gt;
one hardcoded constant, and it was already made configurable for logs six&lt;br&gt;
months ago, in twenty lines.&lt;/p&gt;

&lt;p&gt;Tools are here:&lt;br&gt;
&lt;a href="https://github.com/KanurkarPrateek/Signoz-Git-PR-Review-Latency" rel="noopener noreferrer"&gt;Signoz-Git-PR-Review-Latency&lt;/a&gt;,&lt;br&gt;
including the probe that finds the wall on any OTLP endpoint.&lt;/p&gt;

&lt;p&gt;Verified on SigNoz v0.131.0 and signoz-otel-collector v0.144.5; the constant is&lt;br&gt;
unchanged on &lt;code&gt;main&lt;/code&gt; and in v0.144.6. Your version may differ — run the probe and&lt;br&gt;
find out.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
