<?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: Boris Kl</title>
    <description>The latest articles on DEV Community by Boris Kl (@lamas51).</description>
    <link>https://dev.to/lamas51</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%2F3942385%2F8d8793b0-7612-4b5a-a70c-1d4a8b562b8a.png</url>
      <title>DEV Community: Boris Kl</title>
      <link>https://dev.to/lamas51</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lamas51"/>
    <language>en</language>
    <item>
      <title>Your n8n workflow stopped three days ago. Nobody noticed.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Tue, 11 Aug 2026 17:05:54 +0000</pubDate>
      <link>https://dev.to/lamas51/your-n8n-workflow-stopped-three-days-ago-nobody-noticed-4noa</link>
      <guid>https://dev.to/lamas51/your-n8n-workflow-stopped-three-days-ago-nobody-noticed-4noa</guid>
      <description>&lt;p&gt;The message always has the same shape: "Hey, are the leads still coming through? I don't think I've seen one since Tuesday."&lt;/p&gt;

&lt;p&gt;You open n8n. The workflow is right there. Green. "Active." Looks perfectly healthy. Then you check the executions list and your stomach drops — the last successful run was three days ago. Every trigger since has failed, silently, and the automation just... stopped mattering. No alert. No email. Nothing. It failed the way a light bulb fails: quietly, and you only notice in the dark.&lt;/p&gt;

&lt;p&gt;That gap — "active" on the canvas but dead in reality — is the single most expensive thing about self-hosted automation. Not the crash. The &lt;em&gt;silence&lt;/em&gt; around the crash. Here's why it happens and how to make the next failure announce itself instead of hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "active" lies to you
&lt;/h2&gt;

&lt;p&gt;The word "active" on a workflow only means one thing: the trigger is armed. It says nothing about whether runs are succeeding. A workflow can be active and failing every single time, forever, and n8n will keep showing that calm green dot.&lt;/p&gt;

&lt;p&gt;Three failures cause almost all of the silent ones I get called in for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An upstream API changed and nobody told you.&lt;/strong&gt; A field got renamed. An auth token expired. The endpoint now returns a 429 under load. Your node throws, the execution stops, and unless you wired up something to catch it, that error dies inside the execution log where no human ever looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trigger that quietly stopped firing.&lt;/strong&gt; Polling triggers depend on a schedule. Webhook triggers depend on the sending service still pointing at your URL. If the n8n process restarted and the queue setup is off, the schedule may not come back. The workflow looks active. The trigger is asleep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The process itself fell over.&lt;/strong&gt; Self-hosted n8n on a small VPS hits the OOM killer more than people expect. A big run spikes memory. Linux kills the process. Systemd may or may not bring it back cleanly. Any workflow mid-flight is just gone. No summary. No "3 workflows were interrupted" notice.&lt;/p&gt;

&lt;p&gt;Every one of these looks identical from the dashboard: green, active, fine. That's the whole trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make failure loud: the error workflow
&lt;/h2&gt;

&lt;p&gt;The single highest-value thing you can build in n8n is a workflow whose only job is to tell you when other workflows fail.&lt;/p&gt;

&lt;p&gt;n8n has this built in and almost nobody turns it on. You create one workflow that starts with the &lt;strong&gt;Error Trigger&lt;/strong&gt; node. Then in the settings of every &lt;em&gt;other&lt;/em&gt; workflow, you set that error workflow as the handler. Now any unhandled failure, anywhere, fires it — and you route that to wherever you actually look. Telegram. Slack. Email. Whatever you'll see within the hour.&lt;/p&gt;

&lt;p&gt;The message that error workflow sends should carry enough to act on without opening n8n:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ Workflow failed: "Lead intake → CRM"
Node: HTTP Request (Create contact)
Error: 401 Unauthorized
Time: 2m ago
Execution: &amp;lt;link straight to the run&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the difference between "I found out three days later from a client" and "I knew two minutes after it broke." One error workflow covers your whole instance. You build it once. It's the closest thing to free insurance that self-hosted automation has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failures an error workflow can't catch
&lt;/h2&gt;

&lt;p&gt;Here's the part people miss. An Error Trigger only fires when a workflow &lt;em&gt;runs and throws&lt;/em&gt;. It cannot fire when the workflow never runs at all — a dead poller, a webhook pointed at a URL that no longer exists, a process that's been down for a day. Silence can't trigger an alert about silence.&lt;/p&gt;

&lt;p&gt;So you also need a heartbeat working the other way. A dead man's switch.&lt;/p&gt;

&lt;p&gt;Pick your most important workflow. Have it write a timestamp somewhere cheap on every successful run — a row in a database, a value in Redis, a ping to a free uptime monitor. Then set that monitor to scream if the timestamp goes stale. If "last success" is older than, say, twice the normal interval, something upstream is dead even though nothing threw an error.&lt;/p&gt;

&lt;p&gt;The logic is boring and that's the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;On every successful run:
  update last_success = now()

Separate check, every 15 min:
  if (now() - last_success) &amp;gt; 2 × expected_interval:
    alert("Workflow X hasn't succeeded in too long — trigger may be dead")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Error workflow catches the loud failures. Heartbeat catches the quiet ones. You need both, because they fail in opposite directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop feeding the OOM killer
&lt;/h2&gt;

&lt;p&gt;If your process keeps dying on a small box, throwing more RAM at it is the lazy fix. Two settings do more.&lt;/p&gt;

&lt;p&gt;Cap how much execution history n8n keeps in the database. Logs with no limit are a classic slow bleed of memory and disk. And for workflows that move real volume, turn on queue mode. Executions then run in separate worker processes instead of piling into one. One heavy run costs you a worker, not the whole instance. A crash stops being an all-or-nothing event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset that outlasts any single fix
&lt;/h2&gt;

&lt;p&gt;Automation you can't see is a risk wearing the mask of an asset. The goal was never "set it and forget it." It's "set it, and get told the moment it breaks."&lt;/p&gt;

&lt;p&gt;So the checklist is short. One error workflow wired into every workflow you care about. A heartbeat on the critical ones, watching for silence. Bounded execution history. Queue mode once volume is real. None of it is fancy. All of it turns "a client noticed before I did" into "I fixed it before the client noticed."&lt;/p&gt;

&lt;p&gt;I build and rescue self-hosted n8n setups for people running real business processes through them, and the first thing I check is never the broken node — it's whether anything would have told them it broke.&lt;/p&gt;

&lt;p&gt;So, honest question: if your most important automation died right now, how would you find out — and how long would that take? If the answer is "a customer would tell me," that's the actual bug. What's your alerting setup for the stuff running quietly in the background?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>webdev</category>
      <category>n8n</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Your MCP server works in the demo. Here's why the model still ignores your tool.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Tue, 04 Aug 2026 22:02:30 +0000</pubDate>
      <link>https://dev.to/lamas51/your-mcp-server-works-in-the-demo-heres-why-the-model-still-ignores-your-tool-4d60</link>
      <guid>https://dev.to/lamas51/your-mcp-server-works-in-the-demo-heres-why-the-model-still-ignores-your-tool-4d60</guid>
      <description>&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%2Fh504daru2n4rgpccwk7e.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%2Fh504daru2n4rgpccwk7e.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;The server was done. &lt;code&gt;tools/list&lt;/code&gt; returned all six tools. The inspector lit up green on every one. I wired it into a real chat, asked the kind of question it was built for, and the model reached for the wrong tool. Then it made something up instead of calling the right one at all.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The protocol was fine. The tools ran fine when I called them by hand. The problem was quieter than a bug: a tool the model can technically see, and a tool the model actually uses at the right moment, are two different things.&lt;/p&gt;

&lt;p&gt;Here are the four ways that gap shows up, and how to close each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: a vague description is a dead tool
&lt;/h2&gt;

&lt;p&gt;The model doesn't pick tools by their name. It picks by their description. That's the whole game, and it's the thing people skip.&lt;/p&gt;

&lt;p&gt;A tool called &lt;code&gt;get_data&lt;/code&gt; with the description "fetches data" is invisible in practice. The model has no idea when to reach for it versus the five other tools that also, in some sense, fetch data. So it guesses, or it skips.&lt;/p&gt;

&lt;p&gt;Compare these two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;model&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;shrugs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;one&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fetches data"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;model&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;knows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exactly&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;when&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;use&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;one&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Find orders by customer email or order ID. Returns status, items, and ship date. Use this before answering any question about a specific order — do not guess from memory."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the description for the model, not for a docs page. Say when to call it. Say what comes back. Say how it differs from the neighbor tool that looks similar. That last part matters more than people expect — most "the model called the wrong tool" problems are really "two descriptions that sound the same."&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a fat response poisons the context
&lt;/h2&gt;

&lt;p&gt;A tool returned four thousand tokens of raw JSON to answer "how many open orders are there?" The number was in there. So was everything else. The model waded through the dump, half-answered, and then the next few tool calls came back sloppy because the context was now full of junk it didn't need.&lt;/p&gt;

&lt;p&gt;Your tool's return value isn't a database export. It's an input to the model's next thought. Every token you hand back is a token it has to read on every following turn.&lt;/p&gt;

&lt;p&gt;So return what's needed to make a decision, not everything you have. If the question is a count, return a count. If it's a list, paginate it and say there's more. Add a &lt;code&gt;summary&lt;/code&gt; field the model can lean on instead of parsing the raw rows. A tool that answers tightly keeps the whole conversation sharp. A tool that dumps drags everything after it down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: the schema lies about what it needs
&lt;/h2&gt;

&lt;p&gt;Your input schema is a contract. When it's wrong, the model holds up its end and the server still breaks.&lt;/p&gt;

&lt;p&gt;The usual version: a field is really optional, but the schema doesn't mark it, so the model dutifully invents a value to fill it. Or a field can be null in practice, but nothing says so, so the model sends a string and your handler throws. Then the tool 500s with a stack trace the model can't do anything with, and it either retries the same broken call or gives up.&lt;/p&gt;

&lt;p&gt;Two fixes, and you need both. Make the schema tell the truth — mark what's actually required, mark what's nullable, add a one-line description on each field the way you did for the tool itself. And validate the input on the way in, so that when something's still off, you return a plain-English error the model can read and correct, not a language-runtime traceback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: one tool fails and the whole agent stalls
&lt;/h2&gt;

&lt;p&gt;Five tools, and number three throws an unhandled exception. If that bubbles up as a hard error, the agent loop can just stop. One flaky dependency and the model is stuck, staring at a failure it has no way to route around.&lt;/p&gt;

&lt;p&gt;Catch the error inside the tool and hand it back as something the model can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order service timed out. Try again, or ask the user to retry in a minute."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the model has options. It can retry, try a different tool, or tell the user plainly. A readable error keeps the agent moving. A thrown exception freezes it. Same failure underneath — completely different experience on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually test this
&lt;/h2&gt;

&lt;p&gt;Here's the trap in the trap: the inspector being green tells you the tools &lt;em&gt;run&lt;/em&gt;. It tells you nothing about whether the model &lt;em&gt;chooses&lt;/em&gt; them right. Those are separate questions, and only the second one matters to a user.&lt;/p&gt;

&lt;p&gt;So test the second one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run a real conversation, not a manual tool call. Watch which tool the model reaches for at each step. Wrong pick? That's a description problem, almost every time.&lt;/li&gt;
&lt;li&gt;Log every tool call the model makes — name and arguments. The arguments show you what the model &lt;em&gt;thought&lt;/em&gt; your schema wanted, which is often not what you thought it said.&lt;/li&gt;
&lt;li&gt;Throw an ambiguous request at it, the kind that could match two tools, and see if it lands on the right one. If it coin-flips, your two descriptions aren't distinct enough yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That loop — real chat, watch the choices, fix the descriptions, run it again — is the work. The protocol is the easy part. Getting the model to use your tools the way you meant is the part that takes iterations.&lt;/p&gt;

&lt;p&gt;I build MCP servers and wire up Claude Code setups for teams that want the model to actually use their tools, not just expose them — and this is where most of the time goes, long after &lt;code&gt;tools/list&lt;/code&gt; is green.&lt;/p&gt;

&lt;p&gt;So, honest question: what's the one MCP tool the model keeps refusing to call for you — and did rewriting its description finally fix it, or is it still sitting there ignored? Tell me what it does. I've got a guess about the description.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Nobody checks the VPS between deploys — until it's mining crypto for someone else.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:31:09 +0000</pubDate>
      <link>https://dev.to/lamas51/nobody-checks-the-vps-between-deploys-until-its-mining-crypto-for-someone-else-55on</link>
      <guid>https://dev.to/lamas51/nobody-checks-the-vps-between-deploys-until-its-mining-crypto-for-someone-else-55on</guid>
      <description>&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%2Fx84jl1byw8mi2mjngcem.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%2Fx84jl1byw8mi2mjngcem.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;A friend runs an agency. Six people, forty-something client sites, each on its own small VPS. One Tuesday a client emails asking why their server is "running hot." Load average through the roof. Fan noise from a box that's supposed to just serve a WordPress site. My friend logs in, runs &lt;code&gt;top&lt;/code&gt;, and there it is: a process with a random name, eating every core, phoning home to an IP nobody knows. A miner. It had been there, quietly, for three weeks.&lt;/p&gt;

&lt;p&gt;Nobody had touched that server in three weeks. That's exactly the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap between "deployed" and "watched"
&lt;/h2&gt;

&lt;p&gt;Most small ops setups have decent coverage for one moment: the deploy. CI runs, the app boots, maybe a health check pings once. Then everyone moves on to the next client, the next ticket. What happens on day 12, or day 40, when nobody's looking? Nothing. Nothing gets checked, because nothing &lt;em&gt;triggered&lt;/em&gt; a check. The server just sits there, open to whatever the internet throws at it. Until a client notices the fan. Or the CPU bill. Or worse.&lt;/p&gt;

&lt;p&gt;Scale that across an agency running dozens of servers and the truth gets ugly fast: most of that fleet is watched by nobody, most of the time. Not because anyone's careless. Because there was never a cheap way to watch it all the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full security monitoring is the wrong first step
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for a proper monitoring agent. Install a daemon, ship logs somewhere, wire up a SIEM. That's real work, real cost, real upkeep. For a five-person agency running forty small VPS, it's way too much for the problem at hand. Nobody's rolling out a full security stack just to catch "someone added a cron job."&lt;/p&gt;

&lt;p&gt;But you don't need the whole stack to catch the miner. You need one small thing: &lt;strong&gt;notice when something changes that shouldn't have.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift, not intrusion detection
&lt;/h2&gt;

&lt;p&gt;Think about what actually shifts on a server between deploys, when nothing legitimate is going on. New listening ports. New processes that weren't there last time. New or edited cron entries. New user accounts. A real deploy changes some of this too — but on purpose, in a known, expected way. An intrusion changes it in a way nobody asked for.&lt;/p&gt;

&lt;p&gt;So the check isn't "is this server secure." That's a much bigger question. The check is smaller: is this server the same shape it was yesterday? If not, was that change supposed to happen? That's drift detection, and it's cheaper than it sounds. No agent daemon required.&lt;/p&gt;

&lt;p&gt;A basic version is just three snapshots and a diff:&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="c"&gt;# collect_state.sh — run via cron, once per hour&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"### ports"&lt;/span&gt;
  ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"### processes"&lt;/span&gt;
  ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,user,cmd &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pid
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"### cron"&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;u &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: /etc/passwd&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;crontab &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/log/state/snapshot-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# compare against the last snapshot, alert on anything new&lt;/span&gt;
diff &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PREV_SNAPSHOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LATEST_SNAPSHOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^&amp;gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/new_lines.txt
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /tmp/new_lines.txt &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"New on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/new_lines.txt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No agent on the box, nothing running in the background, just a cron job and a diff. It won't catch a careful attacker who cleans up after themselves, and it's not a substitute for patching or a firewall. But it catches exactly the case above: something new showed up and stayed, because nobody was ever going to notice a strange process name on a server they hadn't opened in three weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this earns its keep
&lt;/h2&gt;

&lt;p&gt;The value isn't in any single alert. It's the fact that a server nobody has logged into for a month still gets checked every hour, automatically, for free. For an agency carrying dozens of client boxes, that's the gap between "we'll find out when the client complains" and "we already know." The miner in my friend's story would have shown up in the first diff, the same hour it started. Not three weeks later, in a support ticket about fan noise.&lt;/p&gt;

&lt;p&gt;I build this kind of lightweight, agentless drift-checking for people who manage a stack of client VPS and don't have a security team. The whole point is that it costs almost nothing to run and doesn't need anyone babysitting it.&lt;/p&gt;

&lt;p&gt;So: if someone SSH'd into one of your servers right now and left a new process running quietly in the background, would anything tell you? Or would you find out the way my friend did?&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>bash</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Your Telegram bot isn't crashing randomly. It's on a timer.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:23:35 +0000</pubDate>
      <link>https://dev.to/lamas51/your-telegram-bot-isnt-crashing-randomly-its-on-a-timer-1igj</link>
      <guid>https://dev.to/lamas51/your-telegram-bot-isnt-crashing-randomly-its-on-a-timer-1igj</guid>
      <description>&lt;p&gt;The ticket always reads the same way: "the bot keeps crashing randomly, please fix."&lt;/p&gt;

&lt;p&gt;So you pull the logs. And it's not ra&lt;br&gt;
ndom at all. The bot dies on a schedule — every few hours, almost to the minute. Like clockwork.&lt;/p&gt;

&lt;p&gt;That rhythm is a gift. Random failures are the hard ones. They smell like memory leaks, race conditions, bad hardware. A failure that lands on a schedule means something &lt;em&gt;expires&lt;/em&gt;. A socket. A token. A connection some box in the middle decided was idle. You're not hunting a ghost. You're looking for a timer.&lt;/p&gt;

&lt;p&gt;Here are the three timers I find over and over in Telegram bots that "crash randomly." Between them they cover most of the rescues I get called in for.&lt;/p&gt;
&lt;h2&gt;
  
  
  Timer #1: the connection that quietly went stale
&lt;/h2&gt;

&lt;p&gt;A long-polling bot holds one HTTP connection open to Telegram and waits. The code looks alive. The process is running. But between your VPS and Telegram sits a NAT table or a proxy. It has an idle timeout. After a fixed window, it drops the connection and tells no one.&lt;/p&gt;

&lt;p&gt;No error on your side. No error on Telegram's side. The socket is just... gone. Your bot keeps waiting on a connection that no longer exists. Depending on the library and its settings, it throws two hours later. Or it hangs forever.&lt;/p&gt;

&lt;p&gt;The fix isn't a bigger server and it isn't switching libraries. It's assuming the connection &lt;em&gt;will&lt;/em&gt; die and reconnecting like you mean it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_forever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.telegram.org/bot&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/getUpdates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# connect, read — never infinite
&lt;/span&gt;            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;upd&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poll failed: %s — reconnecting in %ss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here. A real read timeout, set a bit above the long-poll window. Backoff that grows, so a Telegram hiccup doesn't turn into a hammering loop. And a log line every single time it happens. That last one is the sleeper. If reconnects are silent, you'll never know if they fire once a week or once a minute.&lt;/p&gt;

&lt;p&gt;Frameworks like aiogram and python-telegram-bot handle much of this for you. But I keep meeting bots where someone turned retries off, or wrapped the library in their own loop that swallows the one exception that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer #2: two transports fighting over one bot
&lt;/h2&gt;

&lt;p&gt;Telegram gives you two ways to receive updates: &lt;code&gt;getUpdates&lt;/code&gt; polling or a webhook. One bot token gets exactly one. If a webhook is set and something calls &lt;code&gt;getUpdates&lt;/code&gt;, Telegram answers with &lt;code&gt;409 Conflict&lt;/code&gt;, and plenty of homegrown loops treat that as a fatal crash.&lt;/p&gt;

&lt;p&gt;How do you end up here without noticing? Easier than you'd think. Someone tested a webhook months ago and never deleted it. A deploy script starts a second copy before the first one dies. Or systemd restarts the service, the old process lingers for a minute still holding the connection, and the new one comes up screaming.&lt;/p&gt;

&lt;p&gt;The symptoms look mystical: duplicate messages, updates arriving twice, the bot answering some users and ghosting others, crashes that only happen after a deploy. The cause is boring: two consumers, one queue.&lt;/p&gt;

&lt;p&gt;The check takes ten seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.telegram.org/bot&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;/getWebhookInfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're polling and that response shows a URL — there's your bug. Clear it with &lt;code&gt;deleteWebhook&lt;/code&gt; and pick &lt;em&gt;one&lt;/em&gt; transport for good. Then make sure only one copy of the bot can run. On systemd that's the default, unless your unit file fights it. The ghost-process case usually comes down to &lt;code&gt;KillMode&lt;/code&gt; and &lt;code&gt;TimeoutStopSec&lt;/code&gt; that don't match how the bot really shuts down. I've watched a service restart-loop for hours because a dead process refused to let go of port and token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer #3: the request with no timeout
&lt;/h2&gt;

&lt;p&gt;This one doesn't kill the process. It's worse — the bot stays green in every dashboard while doing absolutely nothing.&lt;/p&gt;

&lt;p&gt;Somewhere in a handler there's a call to an outside API. Weather, payments, a CRM, your own backend. It's written as &lt;code&gt;requests.get(url)&lt;/code&gt;, no timeout, because it worked fine in testing. Then one day that API stops answering but keeps the TCP connection open. The default timeout in &lt;code&gt;requests&lt;/code&gt; is &lt;em&gt;none&lt;/em&gt;. So the handler blocks forever. And in a single-threaded polling loop, that means the whole bot is now a very quiet piece of furniture.&lt;/p&gt;

&lt;p&gt;Users say "the bot stopped replying." Monitoring says everything's fine. The process has been "up" for nine days.&lt;/p&gt;

&lt;p&gt;Every outbound call gets a timeout. No exceptions, including the calls to Telegram itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 5s to connect, 30s to read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if the bot does real work, handlers shouldn't share one thread with the poller at all. Even a small thread pool changes the math. One dead upstream API costs you one worker, not the whole bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the next failure introduce itself
&lt;/h2&gt;

&lt;p&gt;The pattern behind all three: the bot had no way to tell you what was going on. So the fix that outlasts any single bug is a heartbeat. One log line per minute: poll count, last-update time. Plus an alert when the process restarts more than a couple of times an hour. It's cheap to add. And it turns the next "it crashes randomly" into a two-minute read, because now the logs show the rhythm.&lt;/p&gt;

&lt;p&gt;Uptime for a bot isn't a feature you bolt on at the end. It's a mindset. Assume every connection dies. Assume every API hangs. Assume every deploy leaves a ghost behind. Then write the ten extra lines that expect all of it.&lt;/p&gt;

&lt;p&gt;I build and rescue Telegram bots for clients, and this trio is where I look first, before reading a line of business logic.&lt;/p&gt;

&lt;p&gt;What's the strangest clockwork failure you've traced back to a timeout? And do you run bots on webhooks or polling — and why? Genuinely curious what breaks for other people.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>telegram</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I've shipped production code since 1999. AI didn't make me faster where it counts.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Fri, 17 Jul 2026 10:39:05 +0000</pubDate>
      <link>https://dev.to/lamas51/ive-shipped-production-code-since-1999-ai-didnt-make-me-faster-where-it-counts-1jnb</link>
      <guid>https://dev.to/lamas51/ive-shipped-production-code-since-1999-ai-didnt-make-me-faster-where-it-counts-1jnb</guid>
      <description>&lt;p&gt;I wrote my first paid line of code in 1999. Servers you could hear. Deploys over FTP. If something broke at 2am, you SSH'd in and read logs until your eyes hurt.&lt;/p&gt;

&lt;p&gt;So when people tell me AI made them 10x faster, I want to believe it. I use it every day — I build bots on Claude and GPT, I've got AI running in production on my own projects. It's the best tool I've picked up in years.&lt;/p&gt;

&lt;p&gt;But faster? Where it actually counts, no. And I think a lot of us are measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typing was never the bottleneck
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. The slow bit of this job was never the typing.&lt;/p&gt;

&lt;p&gt;I can bang out a CRUD endpoint in ten minutes with or without AI. That was never what ate my week. What ate my week was understanding &lt;em&gt;why&lt;/em&gt; the payment webhook fired twice, or why a site behind a CDN was fast in the test and dead for real users, or which of forty plugins was quietly holding the main thread hostage.&lt;/p&gt;

&lt;p&gt;AI is fantastic at producing code. It's much weaker at understanding a system it can't see — your traffic, your data shape, the three weird decisions someone made in 2021 that everything now depends on. That understanding is the job. The code is just what falls out at the end.&lt;/p&gt;

&lt;p&gt;So when AI writes the endpoint in 30 seconds, I didn't save a week. I saved ten minutes. The week is still there, waiting, in the part AI can't do for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I catch myself in
&lt;/h2&gt;

&lt;p&gt;There's a subtler cost, and it's the one I actually worry about.&lt;/p&gt;

&lt;p&gt;When a tool hands you working code instantly, it's really tempting to skip the understanding step. It runs, tests are green, ship it. I've done it. I've shipped something an AI wrote, felt great about the speed, and then spent two days a week later untangling a problem I'd have seen in five minutes if I'd read the thing properly the first time.&lt;/p&gt;

&lt;p&gt;The code "worked." That's the trap. Working and understood are not the same state, and production only cares about the second one.&lt;/p&gt;

&lt;p&gt;I saw this on a real job — I'll keep it vague. A bot was sending conversion events to an ad platform and the numbers were off. The generated code was clean. It sent the events, got a 200 back, looked perfect in every test. The actual problem was two layers up in how an ID got passed between systems, somewhere no amount of "make the code correct" would ever fix, because the code &lt;em&gt;was&lt;/em&gt; correct. You only find that by understanding the whole path. No model was going to hand me that. I had to sit and think.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually got faster
&lt;/h2&gt;

&lt;p&gt;I don't want to sound like AI is a toy. It's not. Some things genuinely changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The boring first draft.&lt;/strong&gt; Boilerplate, config, a test harness — AI gets me to something I can react to instead of a blank file. Reacting is faster than starting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unfamiliar territory.&lt;/strong&gt; Dropped into a language or an API I don't use often, AI is a better first guide than skimming docs for an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rubber duck that talks back.&lt;/strong&gt; Half the value is just explaining the problem out loud to something that pushes back. I catch my own bad assumptions faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what those have in common. They speed up the &lt;em&gt;edges&lt;/em&gt; — the start, the unfamiliar, the stuck. None of them touch the core: knowing the system well enough to make the right call. That part still runs at human speed, and I've made peace with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 1999 me would recognize this
&lt;/h2&gt;

&lt;p&gt;The tools have changed beyond recognition. The habit that separated the people who could fix things from the people who could only write things — that hasn't changed at all.&lt;/p&gt;

&lt;p&gt;Back then it was the guy who actually read the manual versus the guy who copy-pasted from a forum until it compiled. Both shipped. One of them could fix it at 2am. AI just made the copy-paste path frictionless and fast, which means the gap between "it runs" and "I understand it" is easier than ever to skip over. The discipline of not skipping it is worth more now, not less.&lt;/p&gt;

&lt;p&gt;That's the thing I'd tell someone starting today. Let AI write the code. Then read every line like you'll be the one paged when it breaks — because you will be. Speed at typing is cheap. Understanding is the whole job, and it's the one thing nobody can generate for you.&lt;/p&gt;




&lt;p&gt;I run production systems and build AI-driven tools for a living — the kind of work where "it ran in the demo" isn't good enough. If your stack has one of those problems where the code looks right but the behavior is wrong, that's the part I actually enjoy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Genuine question for the room:&lt;/strong&gt; where has AI actually made you faster — the core work, or just the edges around it? I'd like to know if I'm the outlier here or if we're all quietly measuring the wrong number.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Cloudflare's Flexible SSL looks secure. It isn't.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Fri, 03 Jul 2026 10:45:41 +0000</pubDate>
      <link>https://dev.to/lamas51/cloudflares-flexible-ssl-looks-secure-it-isnt-3kl6</link>
      <guid>https://dev.to/lamas51/cloudflares-flexible-ssl-looks-secure-it-isnt-3kl6</guid>
      <description>&lt;p&gt;A client called: "the site has the padlock, we're done with SSL, right?" Pulled up the Cloudflare panel — Flexible mode. The padlock was real. The encryption wasn't.&lt;/p&gt;

&lt;p&gt;This catches more sites than it should, and the fix is straightforward once you see what's happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare's SSL modes actually mean
&lt;/h2&gt;

&lt;p&gt;There are four, and only one is what most people &lt;em&gt;think&lt;/em&gt; they have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Off&lt;/strong&gt; — no HTTPS. Plain HTTP both ways. Nobody picks this on purpose anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexible&lt;/strong&gt; — HTTPS between the visitor and Cloudflare. &lt;strong&gt;Plain HTTP between Cloudflare and your origin.&lt;/strong&gt; The padlock is the browser's, but the second half of the trip is naked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full&lt;/strong&gt; — HTTPS both ways. Cloudflare doesn't check the origin's certificate, so a self-signed cert is fine. Encrypted, but not authenticated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full (strict)&lt;/strong&gt; — HTTPS both ways, &lt;em&gt;and&lt;/em&gt; Cloudflare verifies the origin cert against a real CA. This is the one you want.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flexible is dangerous because the browser shows the same lock as Full Strict. The site &lt;em&gt;feels&lt;/em&gt; secure to the user, to the developer, to whatever scanner pings it from outside. But the traffic between Cloudflare and the origin server is plaintext.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model people miss
&lt;/h2&gt;

&lt;p&gt;"Plain HTTP between Cloudflare and origin — so what, that's a private link?"&lt;/p&gt;

&lt;p&gt;It isn't. The path from Cloudflare to your origin crosses public internet. Any network in between — a transit provider, a hosting peer, an exit on a managed VPN, a misconfigured router — can read it. Session cookies, login posts, form data, anything not separately encrypted. You spent the budget on Cloudflare to protect the wire, and the back half of the wire is still wide open.&lt;/p&gt;

&lt;p&gt;This is the part the padlock can't tell you about. The browser only sees its own leg of the connection. Cloudflare's leg to your box is invisible to the user, and that's exactly where the leak is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Move to &lt;strong&gt;Full (strict)&lt;/strong&gt;. This is two steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Put a real cert on your origin.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you control the server and run nginx/Apache, the easy answer is Let's Encrypt with certbot — free, 90-day renewal, automated. It's a 10-minute install on most stacks.&lt;/p&gt;

&lt;p&gt;If you can't reach the origin from the public internet (it's locked to Cloudflare's IPs, or behind a firewall that won't let Let's Encrypt's HTTP-01 challenge through), use &lt;strong&gt;Cloudflare's Origin CA&lt;/strong&gt;. It's a separate cert authority Cloudflare runs specifically to issue long-lived (up to 15-year) certs that only Cloudflare itself trusts. Generate one in the Cloudflare dashboard, install on your origin, done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Switch the SSL/TLS mode in Cloudflare to Full (strict).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare dashboard → SSL/TLS → Overview. Flip from Flexible to Full Strict.&lt;/p&gt;

&lt;p&gt;There's an "Always Use HTTPS" toggle nearby — turn it on too. Without it, an attacker can downgrade the first request, the user clicks a plain &lt;code&gt;http://&lt;/code&gt; link, and the connection rides plaintext until Cloudflare upgrades it. Force HTTPS at the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch out for the redirect loop
&lt;/h2&gt;

&lt;p&gt;A mistake I see: site is on Flexible, the origin redirects HTTP→HTTPS at the app level (WordPress with the &lt;code&gt;siteurl&lt;/code&gt; set to &lt;code&gt;https://&lt;/code&gt;, for example). Cloudflare hits the origin on HTTP, the origin sends back a 301 to &lt;code&gt;https://&lt;/code&gt;, Cloudflare follows it back to itself, and you get an infinite loop.&lt;/p&gt;

&lt;p&gt;Symptom: page never loads, browser eventually shows "too many redirects."&lt;/p&gt;

&lt;p&gt;Fix: don't redirect at the origin when you're on Flexible. Or, better, fix the actual problem — move to Full Strict and let the origin always speak HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check what you have right now
&lt;/h2&gt;

&lt;p&gt;In the Cloudflare dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSL/TLS → Overview shows the current mode in plain words.&lt;/li&gt;
&lt;li&gt;SSL/TLS → Edge Certificates shows the cert Cloudflare presents to visitors.&lt;/li&gt;
&lt;li&gt;Edge → Origin Server → look at the cert source — that's the one to check is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From outside, you can also hit your origin's IP directly with &lt;code&gt;curl --resolve&lt;/code&gt; to confirm it serves HTTPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; yourdomain.com:443:&amp;lt;origin_ip&amp;gt; https://yourdomain.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that errors with "SSL certificate problem" or "connection refused on 443," your origin isn't speaking HTTPS at all — Flexible was hiding that.&lt;/p&gt;




&lt;p&gt;The padlock is a useful signal but it doesn't tell the whole story. Flexible SSL exists for a reason — early days, lots of origins didn't have certs — but it's a 2014 compromise and shouldn't be running anywhere in 2026. If you're holding client sites behind Cloudflare, audit the SSL mode on each one. It's a five-second check that catches a real problem.&lt;/p&gt;

&lt;p&gt;I do this kind of edge-hardening for agencies running multiple client sites on Cloudflare — if your SSL setup hasn't been looked at in a while, happy to take a look.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>cloudflare</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Meta CAPI events from a Telegram bot are losing attribution. Here's the fix.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Mon, 29 Jun 2026 19:37:36 +0000</pubDate>
      <link>https://dev.to/lamas51/your-meta-capi-events-from-a-telegram-bot-are-losing-attribution-heres-the-fix-5c07</link>
      <guid>https://dev.to/lamas51/your-meta-capi-events-from-a-telegram-bot-are-losing-attribution-heres-the-fix-5c07</guid>
      <description>&lt;p&gt;A funnel I see all the time: a Meta ad sends people to a landing page, the page says "message us on Telegram," and a bot takes it from there — qualifies the lead, takes the order, whatever. The conversion happens &lt;em&gt;inside the bot&lt;/em&gt;, server-side, so you fire it to Meta with the Conversions API instead of the Pixel.&lt;/p&gt;

&lt;p&gt;Makes sense. Except attribution quietly falls apart, and it took me a while to see why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The click ID is the whole game
&lt;/h2&gt;

&lt;p&gt;Meta matches a server event back to the ad click using &lt;code&gt;fbc&lt;/code&gt;. The format is fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fb.1.&amp;lt;timestamp&amp;gt;.&amp;lt;fbclid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;&amp;lt;fbclid&amp;gt;&lt;/code&gt; comes off the landing URL as &lt;code&gt;?fbclid=...&lt;/code&gt; when someone arrives from the ad. No &lt;code&gt;fbc&lt;/code&gt;, and your CAPI event still lands — but match quality drops and Meta can't tie the conversion to the click. Which is the one thing you actually wanted.&lt;/p&gt;

&lt;p&gt;So the job is: capture &lt;code&gt;fbclid&lt;/code&gt; on the landing page, carry it into the bot, rebuild &lt;code&gt;fbc&lt;/code&gt; there, send it with the event.&lt;/p&gt;

&lt;p&gt;"Carry it into the bot" is where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telegram's deep link won't hold it
&lt;/h2&gt;

&lt;p&gt;You move someone from web into a bot with a deep link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://t.me/your_bot?start=&amp;lt;payload&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bot gets &lt;code&gt;&amp;lt;payload&amp;gt;&lt;/code&gt; as the argument to &lt;code&gt;/start&lt;/code&gt;. Perfect place to stash the click ID, right?&lt;/p&gt;

&lt;p&gt;No. Telegram caps that &lt;code&gt;start&lt;/code&gt; payload at &lt;strong&gt;64 characters&lt;/strong&gt;, and only allows &lt;code&gt;A-Z a-z 0-9 _ -&lt;/code&gt;. A real &lt;code&gt;fbclid&lt;/code&gt; runs way past that — the ones I've measured sit around 170+ characters. It does not fit. Truncate it and it's no longer a valid click ID. URL-encode it and it gets &lt;em&gt;longer&lt;/em&gt;. There's no squeezing the real value through that gate.&lt;/p&gt;

&lt;p&gt;This is the part nobody warns you about. The naive build looks fine in testing with a short fake fbclid, then drops attribution in production with real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: hand over a token, not the value
&lt;/h2&gt;

&lt;p&gt;Don't pass the click ID. Pass a short token that &lt;em&gt;points&lt;/em&gt; to it.&lt;/p&gt;

&lt;p&gt;On the landing page, when the visitor taps through to Telegram:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a short random token (a dozen URL-safe chars is plenty).&lt;/li&gt;
&lt;li&gt;Store &lt;code&gt;{ fbclid, click timestamp, utm params }&lt;/code&gt; against that token server-side — Redis, a KV store, a tiny table.&lt;/li&gt;
&lt;li&gt;Build the deep link with the token: &lt;code&gt;https://t.me/your_bot?start=ab12cd34ef&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the bot, on &lt;code&gt;/start&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startPayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// "ab12cd34ef"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;click&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// { fbclid, ts, utm }&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fbc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`fb.1.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fbclid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendCapiEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;event_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Lead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;action_source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// it happened in a messaging app, not a website&lt;/span&gt;
      &lt;span class="na"&gt;user_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fbc&lt;/span&gt; &lt;span class="cm"&gt;/* + whatever else you have */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// event_id if you also fire a Pixel, so Meta dedups&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// no token? organic /start. Fine — just lower match quality, no fbc.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is short, so it sails through the 64-char limit. The real &lt;code&gt;fbclid&lt;/code&gt; never has to travel through Telegram at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details that bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use the click timestamp, not the bot-open time.&lt;/strong&gt; Store &lt;code&gt;ts&lt;/code&gt; when they hit the landing page. People tap an ad now and open the bot tomorrow — if you stamp &lt;code&gt;fbc&lt;/code&gt; with the moment &lt;code&gt;/start&lt;/code&gt; fired, the window's off. Carry the original click time with the token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;action_source&lt;/code&gt; should tell the truth.&lt;/strong&gt; A bot conversion isn't a &lt;code&gt;website&lt;/code&gt; event — it happened in a messaging app, so the value is &lt;code&gt;chat&lt;/code&gt;. (&lt;code&gt;business_messaging&lt;/code&gt; is Meta's own channels like WhatsApp, not Telegram — don't borrow it.) Meta accepts non-website sources; misreporting it just muddies your own data later.&lt;/p&gt;

&lt;p&gt;And give tokens a TTL. A click that never opens the bot leaves a dangling entry — expire them after a day or two.&lt;/p&gt;




&lt;p&gt;None of this is exotic. It's one indirection — a token standing in for a value that's too big to move. But the failure is silent: events keep arriving, dashboards look populated, and attribution is just... soft, with no error to tell you why. If you're running paid traffic into a chat funnel, check whether your click IDs are actually surviving the handoff.&lt;/p&gt;

&lt;p&gt;I build these bot funnels and the tracking behind them — if yours is leaking attribution somewhere, happy to take a look.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>telegram</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>Approval-driven server ops: how I let contractors restart nginx without ever giving them SSH</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Fri, 26 Jun 2026 23:04:18 +0000</pubDate>
      <link>https://dev.to/lamas51/approval-driven-server-ops-how-i-let-contractors-restart-nginx-without-ever-giving-them-ssh-2fdm</link>
      <guid>https://dev.to/lamas51/approval-driven-server-ops-how-i-let-contractors-restart-nginx-without-ever-giving-them-ssh-2fdm</guid>
      <description>&lt;p&gt;I run a small WordPress + Cloudflare agency. Two recurring pains finally pushed me to build something instead of complaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain 1: contractor access drift.&lt;/strong&gt; I'd hand a contractor root SSH for "just this one task". Three jobs later, I'd realize four ex-contractors still had access I never rotated. The "I'll rotate keys later" lie compounds quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pain 2: 3am Telegram from a client.&lt;/strong&gt; "Site is down" while I'm on the subway with no laptop. Find Wi-Fi, SSH in, &lt;code&gt;systemctl restart nginx&lt;/code&gt;. Five minutes of actual work, ninety minutes of friction.&lt;/p&gt;

&lt;p&gt;The constraint I wanted: &lt;strong&gt;a contractor should be able to restart nginx on a specific server, and nothing else.&lt;/strong&gt; Not "a contractor with a limited shell". Not "a contractor with sudo restricted via sudoers". A contractor with no shell at all, and a chat command that does exactly one thing.&lt;/p&gt;

&lt;p&gt;This article walks the architecture I ended up with. Real components, real grammar — names from the actual source tree. If it's useful, fork the idea — or if you'd rather not build it, link at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the solution
&lt;/h2&gt;

&lt;p&gt;Three components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Operator (Telegram)
  APPROVE: action + nonce + ts (+ TOTP)
        ↓
Python control plane
  - aiogram bot
  - audit hash-chain (SHA-256)
  - policy engine + runtime state (SQLite WAL)
        ↓ mTLS              ↓ HTTPS
   Go agent           Cloudflare API
   (customer's        (under-attack mode,
    server)            challenges, blocks)
   diagnostics +
   allowlisted ops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Go agent sits on the managed server. It speaks one protocol — mTLS HTTP — to the Python control plane. It never accepts shell. It accepts a fixed list of operations declared by a &lt;strong&gt;capability manifest&lt;/strong&gt; loaded at startup.&lt;/p&gt;

&lt;p&gt;The control plane talks to operators via a Telegram bot (aiogram) and to Cloudflare via their REST API. It runs the policy engine, persists state in SQLite (WAL mode), and writes a hash-chained audit log.&lt;/p&gt;

&lt;p&gt;An operator's only interface is a Telegram chat. They never touch the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The APPROVE grammar
&lt;/h2&gt;

&lt;p&gt;Every mutating action goes through this grammar. There is no other path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPROVE: &amp;lt;action&amp;gt; &amp;lt;client_id&amp;gt; nonce=&amp;lt;16-hex&amp;gt; ts=&amp;lt;unix_epoch&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With TOTP enabled (env var &lt;code&gt;SECMON_TELEGRAM_TOTP_SECRET&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPROVE: &amp;lt;action&amp;gt; &amp;lt;client_id&amp;gt; otp=&amp;lt;6digits&amp;gt; nonce=&amp;lt;16-hex&amp;gt; ts=&amp;lt;unix_epoch&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules the control plane enforces against the message:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Action allowlist.&lt;/strong&gt; &lt;code&gt;&amp;lt;action&amp;gt;&lt;/code&gt; must be in the canonical &lt;code&gt;ActionID&lt;/code&gt; enum. Today that's &lt;code&gt;restart-web&lt;/code&gt;, &lt;code&gt;restart-php&lt;/code&gt;, &lt;code&gt;restart-db&lt;/code&gt;, &lt;code&gt;cf-under-attack&lt;/code&gt;, &lt;code&gt;cf-managed-challenge&lt;/code&gt;, &lt;code&gt;cf-targeted-block&lt;/code&gt;, &lt;code&gt;cf-pattern-block&lt;/code&gt;, &lt;code&gt;cf-rate-limit&lt;/code&gt;, &lt;code&gt;enable-cf-auto&lt;/code&gt;. New actions require code + manifest + tests, not configuration. That's deliberate friction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TTL.&lt;/strong&gt; &lt;code&gt;|now - ts| &amp;lt;= 300 seconds&lt;/code&gt;. Stale approvals get rejected, not silently applied.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One-time nonce.&lt;/strong&gt; The 16-hex &lt;code&gt;nonce&lt;/code&gt; is claimed atomically against a SQLite-backed nonce store. Replay = explicit rejection, not silent dedup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TOTP second factor&lt;/strong&gt; (when configured). Six-digit OTP from a pyotp-compatible secret. The control plane refuses the approval if the TOTP doesn't match.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Telegram sender allowlist.&lt;/strong&gt; &lt;code&gt;SECMON_TELEGRAM_ALLOWED_USER_IDS&lt;/code&gt; is checked before parsing. Unknown sender → message logged, action not even parsed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those fails, the agent never gets a request — the rejection happens in the control plane, gets written to the audit log with reason, and the operator gets a curt Telegram reply explaining what was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The capability manifest
&lt;/h2&gt;

&lt;p&gt;The agent advertises its capabilities via a JSON document, signed at deploy time with HMAC-SHA256.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="nf"&gt;body &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HMAC&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;SHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECMON_MANIFEST_SECRET&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the agent comes up, it loads &lt;code&gt;capabilities.manifest.json&lt;/code&gt;. When the control plane dispatches an action, it pulls the agent's manifest, verifies the HMAC, and refuses anything not in the signed list. The agent cannot lie about what it supports — and the control plane cannot accidentally route a &lt;code&gt;restart-db&lt;/code&gt; to a host whose manifest only declared &lt;code&gt;restart-web&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the cheap version of the right idea. The expensive version is signed-everything-everywhere. HMAC-SHA256 with a shared secret is enough for "operator wants X, agent is allowed to do X". For multi-tenant trust I'd reach for asymmetric, but for a self-hosted agent paired with a single control plane, shared HMAC is good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit log — hash-chained, verifiable offline
&lt;/h2&gt;

&lt;p&gt;Every approval, every dispatch, every Cloudflare auto-mitigation gets a JSON record in an append-only JSONL file. Each record carries &lt;code&gt;prev_hash&lt;/code&gt; and &lt;code&gt;record_hash&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;prev_hash&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;record_hash&lt;/span&gt; &lt;span class="n"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;
&lt;span class="n"&gt;record_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_hash&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;prev_hash&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record_hash&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;preceding&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first record has &lt;code&gt;prev_hash = ""&lt;/code&gt;. Every subsequent record locks in everything before it. Tamper with any line, every line after fails verification.&lt;/p&gt;

&lt;p&gt;Verification has a CLI: &lt;code&gt;secmon-audit-verify&lt;/code&gt;. Walks the chain, returns line numbers of any break.&lt;/p&gt;

&lt;p&gt;The threat model is "compromised SaaS cannot lie about what your servers did, after the fact". I think that's the audit threat model that actually matters for an agency that has to show a client what happened during an incident.&lt;/p&gt;

&lt;p&gt;(The audit log is &lt;strong&gt;separate&lt;/strong&gt; from the licensing JWT, which uses Ed25519. Different concern. Don't conflate them.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately is NOT
&lt;/h2&gt;

&lt;p&gt;This is just as important as the feature list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a monitoring stack.&lt;/strong&gt; Use Prometheus, UptimeRobot, Grafana. This tool reacts to incidents — it doesn't detect them. The diagnostics module on the agent is read-only triage (&lt;code&gt;/diag&lt;/code&gt;), not continuous monitoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a remote desktop.&lt;/strong&gt; No raw shell exec. If you want SSH, you want SSH; this isn't a worse SSH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not DDoS protection.&lt;/strong&gt; Cloudflare is your shield. The control plane &lt;em&gt;can&lt;/em&gt; auto-dispatch a Cloudflare under-attack mode in response to certain patterns — but auto-dispatch has a hard kill-switch (&lt;code&gt;SECMON_AUTO_CF_DISABLED=1&lt;/code&gt;) checked on &lt;strong&gt;every&lt;/strong&gt; dispatch path. Manual &lt;code&gt;APPROVE: cf-under-attack&lt;/code&gt; still works as an explicit operator override, with &lt;code&gt;auto_dispatch_overridden_by=approve&lt;/code&gt; written to audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a ticketing system.&lt;/strong&gt; No "open ticket for this incident". The audit log is the record, your team's normal tools do the workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every feature I didn't add is a feature I don't have to maintain. At my scale, that math dominates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model: assume the bot token leaks
&lt;/h2&gt;

&lt;p&gt;The Telegram bot token gets passed around. Plan for the leak.&lt;/p&gt;

&lt;p&gt;If the bot token leaks alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Attacker can spam the bot's Telegram channel.&lt;/li&gt;
&lt;li&gt;Attacker &lt;strong&gt;cannot&lt;/strong&gt; invoke an action, because:

&lt;ul&gt;
&lt;li&gt;Their Telegram user ID isn't in &lt;code&gt;SECMON_TELEGRAM_ALLOWED_USER_IDS&lt;/code&gt;. Message gets logged, parsing never starts.&lt;/li&gt;
&lt;li&gt;Even if they were on the allowlist, they don't have the TOTP secret.&lt;/li&gt;
&lt;li&gt;Even if they had the TOTP, they can't reuse a stolen nonce (one-time, atomic claim).&lt;/li&gt;
&lt;li&gt;Even with a fresh nonce, &lt;code&gt;|now - ts| &amp;gt; 300s&lt;/code&gt; kills any captured approval.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Attacker &lt;strong&gt;cannot&lt;/strong&gt; elevate themselves into the allowlist or the TOTP secret — both are env-var on the control plane host. Compromising the host changes the threat model entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To actually run an operation against a server, the attacker would need all of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bot token (Telegram side)&lt;/li&gt;
&lt;li&gt;TOTP secret (env var on control plane)&lt;/li&gt;
&lt;li&gt;Telegram user ID on the allowlist (env var on control plane)&lt;/li&gt;
&lt;li&gt;A fresh, unused nonce (or write access to the nonce store)&lt;/li&gt;
&lt;li&gt;A timestamp within ±300s of dispatch&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the design point. Each layer is cheap to add and forces an attacker to compromise distinct surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few honest reflections:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TOTP belongs in v1, not v3.&lt;/strong&gt; I shipped the bot + APPROVE grammar first, added nonce/TTL in a "security hardening" sprint, added TOTP in another security hardening sprint. Should have been one combined hardening pass before the first real customer touched it. I got away with it because the first customer was me, but I wouldn't ship a v2 without it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The hash-chain is great until it isn't.&lt;/strong&gt; A corrupt last line used to be swallowed silently — new events would be written with &lt;code&gt;prev_hash=""&lt;/code&gt; and produce a "verifiable but forked" log. Fixed by explicitly propagating &lt;code&gt;AuditChainError&lt;/code&gt; on read failure or JSON parse failure, with an opt-in manual recovery path. Lesson: tamper-evidence is a property of the &lt;em&gt;whole&lt;/em&gt; chain, including the read path, not just the write path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Per-tenant Cloudflare credentials need their own design.&lt;/strong&gt; I had a single CF token at the start. That works for one client. For multi-client it became per-&lt;code&gt;(client_id, zone_id)&lt;/code&gt; rows encrypted at rest with Fernet (&lt;code&gt;SECMON_DATA_KEY&lt;/code&gt;). Earlier this had a one-key-per-deployment shape and I had to migrate — should have started multi-tenant from the first commit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Diagnostics is bigger than ops.&lt;/strong&gt; The agent's read-only diagnostics module (HTTP, web, PHP, DB, disk, SSL, flood patterns) is about 1.5K lines. The mutating-ops surface is smaller. That ratio is honest: you spend much more time looking than fixing, and read-only checks have to be deeply specific or they're noise.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If you run client infrastructure and the "shared root password" thing makes you wince, the architecture above is buildable. The Go agent is a single binary with no runtime deps. The control plane is Python with SQLite. Nothing exotic in the stack.&lt;/p&gt;

&lt;p&gt;If you'd rather not build it: I packaged this up as &lt;a href="https://secmon.io/" rel="noopener noreferrer"&gt;secmon.io&lt;/a&gt;. Pricing is $49/mo Starter (1 server, 4h response), $199 Pro (5 servers, 1h response), $599 Agency (20 servers, monthly incident review). 7-day pilot on all tiers. The agent source is currently closed; whether to open-source it under AGPL is on the roadmap if there's demand — happy to discuss the trade-offs in comments.&lt;/p&gt;

&lt;p&gt;If you'd like to be one of the first ten pilots, the contact form is on the homepage. Honest feedback is more useful to me right now than your money.&lt;/p&gt;

&lt;p&gt;Critique very welcome — what's broken in the threat model? What's the obvious bigger competitor I missed?&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Your page loads fast but still feels slow? It's INP, not load time</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Tue, 16 Jun 2026 10:27:25 +0000</pubDate>
      <link>https://dev.to/lamas51/your-page-loads-fast-but-still-feels-slow-its-inp-not-load-time-2gkn</link>
      <guid>https://dev.to/lamas51/your-page-loads-fast-but-still-feels-slow-its-inp-not-load-time-2gkn</guid>
      <description>&lt;p&gt;Your Lighthouse report is mostly green. LCP is fine, CLS is fine, the page loads fast. Then the real-world score drops and you can't see why. Nine times out of ten the culprit is INP — and it's the one metric a quick Lighthouse run barely shows you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What INP actually measures
&lt;/h2&gt;

&lt;p&gt;INP, short for Interaction to Next Paint, replaced FID as a Core Web Vital in March 2024. FID only looked at the delay before your &lt;em&gt;first&lt;/em&gt; interaction. INP looks at &lt;em&gt;all&lt;/em&gt; of them, the whole time someone uses the page, and reports close to the worst one.&lt;/p&gt;

&lt;p&gt;So it's not a loading metric. It's a responsiveness metric. It answers a different question: when I tap, click, or type, how long until the screen actually changes? Google's buckets are simple — 200ms or under is good, over 500ms is poor.&lt;/p&gt;

&lt;p&gt;That's why a site can load in a second and still fail. Loading fast and responding fast are two different jobs, done by two different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's invisible in a normal audit
&lt;/h2&gt;

&lt;p&gt;LCP and CLS happen during load, so a lab tool catches them every run. INP only happens when a human interacts. Lighthouse doesn't tap your buttons, so its number is an estimate at best. You can have a green lab report and a red field score at the same time, and that gap is exactly where people get stuck.&lt;/p&gt;

&lt;p&gt;To see the real number, measure interactions as they happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onINP&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web-vitals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That logs the actual slow interaction and the element behind it. Now you're fixing a real thing instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's really slow
&lt;/h2&gt;

&lt;p&gt;INP is almost always one thing: the main thread was busy when the user acted. The browser can't paint the response until the current JavaScript task finishes, so a long task blocks the interaction.&lt;/p&gt;

&lt;p&gt;The usual sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A heavy event handler doing real work on every click or keystroke.&lt;/li&gt;
&lt;li&gt;Third-party scripts like chat widgets, analytics, and tag managers, running long tasks at the wrong moment.&lt;/li&gt;
&lt;li&gt;Layout thrash: reading and writing the DOM in a loop so the browser recalculates over and over.&lt;/li&gt;
&lt;li&gt;Framework hydration waking the whole page up at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fixes that move it
&lt;/h2&gt;

&lt;p&gt;Break up long tasks. If a handler does a lot, let the browser breathe partway through instead of holding the thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;doUrgentPart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// update the UI first&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;yieldToMain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// give the browser a turn to paint&lt;/span&gt;
  &lt;span class="nf"&gt;doExpensivePart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// the rest can wait a tick&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;yieldToMain&lt;/code&gt; is a one-line helper around &lt;code&gt;scheduler.yield()&lt;/code&gt; where it's supported, or a &lt;code&gt;setTimeout(0)&lt;/code&gt; fallback. The trick is to paint the response &lt;em&gt;before&lt;/em&gt; the slow work, not after.&lt;/p&gt;

&lt;p&gt;Beyond that: defer scripts the page doesn't need to react, audit third-party widgets for the ones that run long tasks, debounce expensive handlers, and batch your DOM reads and writes so the browser isn't recalculating layout on every line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;I won't promise you a magic number — INP depends on your scripts, your theme, and what your users actually click. But it's measurable, and the field data shows the difference plainly once the long tasks are gone.&lt;/p&gt;

&lt;p&gt;I keep my own WordPress sites in the green on Core Web Vitals, and INP is the one I watch most now, because it's the one that quietly fails while everything else looks fine. If your lab report is green but the real score isn't, stop staring at LCP. Go measure an interaction.&lt;/p&gt;

</description>
      <category>webperf</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Telegram bot replies twice? It's timing, not a logic bug</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:23:49 +0000</pubDate>
      <link>https://dev.to/lamas51/your-telegram-bot-replies-twice-its-timing-not-a-logic-bug-2f4j</link>
      <guid>https://dev.to/lamas51/your-telegram-bot-replies-twice-its-timing-not-a-logic-bug-2f4j</guid>
      <description>&lt;p&gt;A Telegram bot replies to the same message twice. An n8n flow processes an order, then processes it again ten seconds later. The owner reads the handler code, finds nothing wrong, and assumes the logic is broken.&lt;/p&gt;

&lt;p&gt;It usually isn't. These bugs are almost always about timing, not logic — and once you know the three places timing bites, they stop being mysterious.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The webhook you never answered
&lt;/h2&gt;

&lt;p&gt;Telegram (and most webhook senders) wait for an HTTP 200. If your endpoint does the work first and answers afterward, a slow database call or a third-party API can push you past the timeout. The sender assumes delivery failed and sends the same update again. Now your "double reply" isn't a logic bug — it's the same event arriving twice because you were too slow to say "got it."&lt;/p&gt;

&lt;p&gt;The fix is to acknowledge first, process second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# hand off
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# answer immediately
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Return 200 the moment you've safely stored the update. Do the real work in a background task or a worker. The sender stops retrying, and the duplicates dry up.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. No dedup, so retries become real work
&lt;/h2&gt;

&lt;p&gt;Answering fast helps, but retries still happen — network blips, restarts, a sender that's feeling anxious. The honest assumption is: every event can arrive more than once. So make handling it twice harmless.&lt;/p&gt;

&lt;p&gt;Every Telegram update has an &lt;code&gt;update_id&lt;/code&gt;. Every message has a &lt;code&gt;message_id&lt;/code&gt;. Most webhook payloads have some stable id. Key on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;            &lt;span class="c1"&gt;# already handled, do nothing
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;seen&lt;/code&gt; can be Redis, a unique column in your database, anything that's shared across workers. The point is that "process this order" runs once even if the event shows up three times. People call this idempotency; it just means doing it again changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two messages, one piece of state, no lock
&lt;/h2&gt;

&lt;p&gt;This is the one that looks the most like a logic bug and isn't. A user double-taps a button. Two updates arrive almost together. Both handlers read "balance: 100", both subtract 30, both write "70". You charged once for two actions, or booked the same slot twice.&lt;/p&gt;

&lt;p&gt;Nothing in the logic is wrong. The two runs just overlapped. The fix is to stop them from overlapping on the same state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;set_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A per-user lock (Redis &lt;code&gt;SET NX&lt;/code&gt;, a database row lock, whatever you have) means update B waits for update A to finish before it touches the same row. In n8n the same idea shows up as a queue or a "wait for previous execution" step instead of letting every webhook fire its own parallel run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that saves you next time
&lt;/h2&gt;

&lt;p&gt;Most of these never get diagnosed because they're invisible. The handler "works" when you test it by hand — you can't tap fast enough to cause the race, and your local webhook answers instantly. It only breaks under real traffic, at 3am, where you're not looking.&lt;/p&gt;

&lt;p&gt;So log the timing, not just the errors. Log the &lt;code&gt;update_id&lt;/code&gt; on the way in and the way out. Log when a lock is contended. The first time you see the same &lt;code&gt;update_id&lt;/code&gt; logged twice, the whole thing stops being a mystery and becomes a one-line fix.&lt;/p&gt;

&lt;p&gt;I run Telegram bots and n8n in production every day, and I've hit all three of these. None of them were in the logic. They were in the gaps between events — and that's almost always where to look first.&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sending Telegram Bot Conversions to Meta? Don't Reach for business_messaging</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Sun, 14 Jun 2026 13:45:21 +0000</pubDate>
      <link>https://dev.to/lamas51/sending-telegram-bot-conversions-to-meta-dont-reach-for-businessmessaging-1ecj</link>
      <guid>https://dev.to/lamas51/sending-telegram-bot-conversions-to-meta-dont-reach-for-businessmessaging-1ecj</guid>
      <description>&lt;p&gt;A bot was firing Subscribe and Purchase events from Telegram straight to Meta's Conversions API, and every call came back with a 400:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"error_user_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Missing Messaging Channel Parameter"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"error_user_msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A messaging channel parameter is required when provided
                   action source is business_messaging. Valid value could be
                   messenger, whatsapp and instagram."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload looked fine — &lt;code&gt;event_name&lt;/code&gt;, &lt;code&gt;event_time&lt;/code&gt;, a hashed &lt;code&gt;external_id&lt;/code&gt;, and &lt;code&gt;action_source: 'business_messaging'&lt;/code&gt;. So why the 400?&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;business_messaging&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; a generic "it happened in a chat" source. Meta ties it to its own messaging products, and it demands a companion &lt;code&gt;messaging_channel&lt;/code&gt; whose only valid values are &lt;code&gt;messenger&lt;/code&gt;, &lt;code&gt;whatsapp&lt;/code&gt;, &lt;code&gt;instagram&lt;/code&gt;. Telegram isn't on that list — there's no channel you can hand it — so the request can never validate.&lt;/p&gt;

&lt;p&gt;The instinct is to try &lt;code&gt;app&lt;/code&gt; next. Don't. &lt;code&gt;app&lt;/code&gt; drags in a required &lt;code&gt;app_data&lt;/code&gt; block: the extinfo array, advertiser tracking flags, the whole mobile-SDK surface. You don't have that from a bot, and you don't want to fake it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;For a self-hosted Telegram bot, the right source is plain &lt;strong&gt;&lt;code&gt;other&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;other&lt;/code&gt; has no extra mandatory fields. You need &lt;code&gt;event_name&lt;/code&gt;, &lt;code&gt;event_time&lt;/code&gt;, &lt;code&gt;action_source&lt;/code&gt;, and a &lt;code&gt;user_data&lt;/code&gt; with at least one identifier. A SHA-256 hashed Telegram user id as &lt;code&gt;external_id&lt;/code&gt; is enough to clear the 400. One-line change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it actually attribute
&lt;/h2&gt;

&lt;p&gt;Not crashing is the low bar. To tie a Subscribe or Purchase back to the ad that caused it, you need the click id:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture &lt;code&gt;fbc&lt;/code&gt;.&lt;/strong&gt; Your ad sends people to a deep link — &lt;code&gt;t.me/yourbot?start=...&lt;/code&gt;. Meta appends &lt;code&gt;fbclid&lt;/code&gt; to that destination. Pack the &lt;code&gt;fbclid&lt;/code&gt; into the &lt;code&gt;start&lt;/code&gt; payload, read it on &lt;code&gt;/start&lt;/code&gt;, and build &lt;code&gt;fbc = fb.1.[unix_time].[fbclid]&lt;/code&gt;. Send it in &lt;code&gt;user_data&lt;/code&gt; next to &lt;code&gt;external_id&lt;/code&gt;. This is the single biggest lever for matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purchase needs money.&lt;/strong&gt; Add &lt;code&gt;custom_data&lt;/code&gt; with &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;currency&lt;/code&gt;, or there's no ROAS to compute later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before you trust it.&lt;/strong&gt; Events Manager has a Test Events tab — send with a &lt;code&gt;test_event_code&lt;/code&gt; and watch the events land and match before you point real traffic at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe&lt;/strong&gt; if a web pixel fires the same events: same &lt;code&gt;event_id&lt;/code&gt; on both sides and Meta collapses them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 400 is a five-second fix. The attribution is the part that actually pays for itself.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>meta</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Lighthouse Gave My Site 100/100. The Site Was Down.</title>
      <dc:creator>Boris Kl</dc:creator>
      <pubDate>Thu, 11 Jun 2026 18:56:58 +0000</pubDate>
      <link>https://dev.to/lamas51/lighthouse-gave-my-site-100100-the-site-was-down-3gin</link>
      <guid>https://dev.to/lamas51/lighthouse-gave-my-site-100100-the-site-was-down-3gin</guid>
      <description>&lt;p&gt;Yesterday I ran PageSpeed Insights on a site I manage. Performance: &lt;strong&gt;100/100&lt;/strong&gt;. Green circle, confetti, the works.&lt;/p&gt;

&lt;p&gt;One problem: the screenshot in the report showed a Cloudflare block page — "Sorry, you have been blocked."&lt;/p&gt;

&lt;p&gt;Lighthouse didn't measure my site. It measured the &lt;em&gt;error page&lt;/em&gt; my WAF served to Google's crawler. And error pages are, of course, blazing fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this happens
&lt;/h2&gt;

&lt;p&gt;If you put Cloudflare in front of a site and turn the security dial up (Bot Fight Mode, aggressive WAF rules, country blocks), you'll eventually block more than bots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PageSpeed Insights / Lighthouse&lt;/strong&gt; — measures a block page, reports nonsense&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime monitors&lt;/strong&gt; — see HTTP 403 with a 200-ish body, or vice versa, and lie to you either way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google's crawler itself&lt;/strong&gt; — and that one quietly costs you rankings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nasty part is the &lt;em&gt;silence&lt;/em&gt;. Nothing looks broken from your own browser, because you're whitelisted by your own cookies, IP reputation, or login session. The tools just start telling you fairy tales.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-minute audit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Cloudflare → Security → Events&lt;/strong&gt;. Filter the last 7 days. Look at what's actually being challenged or blocked — you'll usually find a legit service in there within a minute.&lt;/li&gt;
&lt;li&gt;Check the user agents: &lt;code&gt;Chrome-Lighthouse&lt;/code&gt;, &lt;code&gt;GoogleOther&lt;/code&gt;, &lt;code&gt;Googlebot&lt;/code&gt;, your uptime checker. If they show up here, that traffic never reached your site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify bots properly&lt;/strong&gt;: Cloudflare has a "Verified Bots" category — allow it instead of hand-maintaining user-agent allowlists (user agents are trivially faked; verified-bot checks aren't).&lt;/li&gt;
&lt;li&gt;Re-run your measurement and &lt;em&gt;look at the rendered screenshot&lt;/em&gt;, not just the score. The screenshot is the only part of a Lighthouse report that can't lie to you.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rules I now follow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never trust a perfect score.&lt;/strong&gt; 100/100 on a real WordPress/commerce site is a smell, not an achievement. Real sites have real images and real JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the screenshot first&lt;/strong&gt;, score second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After every WAF change, re-test from outside&lt;/strong&gt;: different network, curl with a Googlebot UA, or just PageSpeed Insights — and read the Events log after.&lt;/li&gt;
&lt;li&gt;Monitoring that runs &lt;em&gt;behind&lt;/em&gt; your own allowlist isn't monitoring. It's a mirror.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloudflare is still the best free thing that ever happened to small sites — I run my own production behind it and it has eaten real attack waves for breakfast. But a security layer you configured and never audited is just a random traffic filter with good branding.&lt;/p&gt;

&lt;p&gt;Five minutes in the Events log. That's the whole tip.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webperf</category>
      <category>devops</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
