<?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: Kunal</title>
    <description>The latest articles on DEV Community by Kunal (@kunal_d6a8fea2309e1571ee7).</description>
    <link>https://dev.to/kunal_d6a8fea2309e1571ee7</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%2F2621382%2Fc94c296d-7804-4c0c-accc-b8f5900821ac.jpg</url>
      <title>DEV Community: Kunal</title>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kunal_d6a8fea2309e1571ee7"/>
    <language>en</language>
    <item>
      <title>Prevent API Key Leaks in Shell History (bash/zsh/fish) [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sun, 06 Sep 2026 00:42:01 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/prevent-api-key-leaks-in-shell-history-bashzshfish-2026-1ikg</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/prevent-api-key-leaks-in-shell-history-bashzshfish-2026-1ikg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/prevent-api-key-leaks-shell-history" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can lock down 80% of “oops I leaked a key” incidents in about 20 minutes. The prerequisite that trips people up is this: &lt;strong&gt;your shell will happily persist whatever you type&lt;/strong&gt; (history files, dotfiles, exports), and modern AI/agentic tooling has made us paste secrets into terminals more often than ever.&lt;/p&gt;

&lt;p&gt;This guide is specifically about how to &lt;strong&gt;prevent API key leaks in shell history&lt;/strong&gt; across bash, zsh, and fish. I’ll give you copy-pasteable hardening snippets, the safer way to pass secrets to CLIs locally, how to use &lt;code&gt;direnv&lt;/code&gt; and &lt;code&gt;mise&lt;/code&gt; without committing keys, and what to do when a key still leaks.&lt;/p&gt;

&lt;p&gt;Based on the keyword neighborhood signals I pulled from my own site’s research tooling, this is a winnable problem space for teams: &lt;strong&gt;99 related impressions&lt;/strong&gt; in the “prevent secret leaks” neighborhood, with a &lt;strong&gt;best observed position ~2.7&lt;/strong&gt;, across an estimated &lt;strong&gt;~460 searches/month&lt;/strong&gt; worth of adjacent queries. In other words: people are searching for this. And they’re mostly getting bad advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is terminal secrets hygiene?
&lt;/h2&gt;

&lt;p&gt;Terminal secrets hygiene is the set of practices that prevents secrets you type or load in a terminal (API keys, tokens, passwords) from being permanently stored in shell history, environment exports, dotfiles, logs, screenshots, or git.&lt;/p&gt;

&lt;p&gt;The reason I’m opinionated about this: the “just don’t do that” approach fails the minute you’re debugging a production issue at 2 a.m. or copy/pasting a vendor’s &lt;code&gt;curl&lt;/code&gt; example into a terminal. You need guardrails that work when you’re tired.&lt;/p&gt;

&lt;p&gt;Here’s the checklist we’re going to implement:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Configure bash/zsh/fish to &lt;strong&gt;not record&lt;/strong&gt; the most common secret-bearing commands.&lt;/li&gt;
&lt;li&gt;Stop using global &lt;code&gt;export&lt;/code&gt; as your default. Scope secrets to the smallest possible lifetime.&lt;/li&gt;
&lt;li&gt;Keep secrets out of dotfiles and repos. Use local-only files.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;direnv&lt;/code&gt; and &lt;code&gt;mise&lt;/code&gt; to load per-project secrets safely.&lt;/li&gt;
&lt;li&gt;Add secret scanning so you get yelled at before GitHub does.&lt;/li&gt;
&lt;li&gt;Have a runbook for when a key leaks anyway.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Shell history basics: where history is stored and why secrets leak
&lt;/h2&gt;

&lt;p&gt;Before settings, you need the mental model.&lt;/p&gt;

&lt;p&gt;Most shells append your commands to a plain-text history file in your home directory. On macOS and Linux, it’s typically one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bash: &lt;code&gt;~/.bash_history&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;zsh: &lt;code&gt;~/.zsh_history&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;fish: &lt;code&gt;~/.local/share/fish/fish_history&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That history file is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;readable by you (obviously)&lt;/li&gt;
&lt;li&gt;often backed up (Time Machine, corporate laptop backup agents)&lt;/li&gt;
&lt;li&gt;often copied around when people sync dotfiles across machines&lt;/li&gt;
&lt;li&gt;sometimes exfiltrated by infostealers that target developer machines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most common leak patterns I see in real teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A vendor doc says: &lt;code&gt;curl -H "Authorization: Bearer sk_live_..." ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Someone types: &lt;code&gt;export AWS_SECRET_ACCESS_KEY=...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Someone puts &lt;code&gt;export OPENAI_API_KEY=...&lt;/code&gt; into &lt;code&gt;~/.zshrc&lt;/code&gt; “temporarily” and forgets&lt;/li&gt;
&lt;li&gt;Someone shares a terminal screenshot to Slack with the token visible&lt;/li&gt;
&lt;li&gt;An agentic CLI tool logs the full command line, then you paste that log into a ticket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitHub has an entire product area dedicated to stopping this class of mistake, because it happens constantly: &lt;strong&gt;Secret scanning / GitHub Secret Protection&lt;/strong&gt; is designed to detect secrets committed to repos and help you remediate them (&lt;a href="https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Also, AI tooling makes this worse. As &lt;strong&gt;Dwayne McDaniel of GitGuardian&lt;/strong&gt; puts it in their write-up on agentic workflows, when tools can act across systems, “the more systems an agent can reach, the more consequential a failure in its credential and execution layer becomes” (&lt;a href="https://dev.to/gitguardian/securing-agentic-ai-workflows-in-n8n-from-leaked-api-keys-to-encryption-key-compromise-5861"&gt;Dwayne McDaniel&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;So yes, history settings matter. But they’re not the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop a command from being saved in shell history (bash/zsh/fish)?
&lt;/h2&gt;

&lt;p&gt;You’ve got three practical moves, in order of usefulness:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use “ignore space”&lt;/strong&gt; rules (bash/zsh). Prefix sensitive commands with a space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use shell-specific “don’t log this” options&lt;/strong&gt; (zsh has more knobs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a private/no-history session&lt;/strong&gt; when you’re about to do sketchy things (fish has an explicit mode).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here’s the cross-shell cheat sheet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shell&lt;/th&gt;
&lt;th&gt;Fastest “don’t save this” option&lt;/th&gt;
&lt;th&gt;Best persistent hardening&lt;/th&gt;
&lt;th&gt;Where to put it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bash&lt;/td&gt;
&lt;td&gt;Prefix command with a leading space (with &lt;code&gt;HISTCONTROL=ignorespace&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HISTCONTROL=ignoreboth&lt;/code&gt; + &lt;code&gt;HISTIGNORE&lt;/code&gt; patterns&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.bashrc&lt;/code&gt; / &lt;code&gt;~/.bash_profile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zsh&lt;/td&gt;
&lt;td&gt;Prefix command with a leading space (with &lt;code&gt;setopt HIST_IGNORE_SPACE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;setopt HIST_IGNORE_SPACE&lt;/code&gt; + &lt;code&gt;HIST_SAVE_NO_DUPS&lt;/code&gt; + sane file perms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.zshrc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fish&lt;/td&gt;
&lt;td&gt;Start &lt;code&gt;fish --private&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;private sessions for sensitive work; avoid storing secrets in vars&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;config.fish&lt;/code&gt; (settings)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you only do one thing today, do the leading-space rule. It’s low friction and catches the “copy/paste a curl with a token” habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bash: history controls (HISTCONTROL/HISTIGNORE) and safe patterns
&lt;/h2&gt;

&lt;p&gt;Bash is blunt but effective.&lt;/p&gt;

&lt;p&gt;Add this to &lt;code&gt;~/.bashrc&lt;/code&gt; (or wherever your bash config lives):&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;# --- secrets hygiene: bash history ---&lt;/span&gt;
&lt;span class="c"&gt;# ignoreboth = ignorespace + ignoredups&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HISTCONTROL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ignoreboth

&lt;span class="c"&gt;# Keep history smaller. Big history = bigger blast radius.&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HISTSIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HISTFILESIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000

&lt;span class="c"&gt;# Don't record obvious secret-bearing commands. Tune for your stack.&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HISTIGNORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*--password*:*--token*:*--secret*:*Authorization:*:export *KEY=*:*AWS_SECRET_ACCESS_KEY*:*OPENAI_API_KEY*:*GITHUB_TOKEN*'&lt;/span&gt;

&lt;span class="c"&gt;# Ensure history is appended (not overwritten) in multi-shell usage&lt;/span&gt;
&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; histappend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ignoreboth&lt;/code&gt; means any command starting with a space is ignored, and duplicates are ignored.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HISTIGNORE&lt;/code&gt; is pattern-based filtering. It’s not perfect, but it catches the “export KEY=…” and “curl -H Authorization: …” stuff.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HISTSIZE&lt;/code&gt; and &lt;code&gt;HISTFILESIZE&lt;/code&gt; are boring but important. If your history file is 2 MB instead of 200 MB, you have less to scrub when something goes wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more thing people miss: permissions.&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="nb"&gt;chmod &lt;/span&gt;600 ~/.bash_history 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’re on a shared box, “world-readable history files” is an embarrassment you don’t want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zsh: history controls (setopt options) and safe patterns
&lt;/h2&gt;

&lt;p&gt;Zsh has more knobs, and you should use them.&lt;/p&gt;

&lt;p&gt;Add this to &lt;code&gt;~/.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- secrets hygiene: zsh history ---&lt;/span&gt;
&lt;span class="c"&gt;# Ignore commands that start with a space&lt;/span&gt;
setopt HIST_IGNORE_SPACE

&lt;span class="c"&gt;# Reduce duplicate noise and accidental repeats&lt;/span&gt;
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_SAVE_NO_DUPS

&lt;span class="c"&gt;# Write history incrementally so you don't lose it. (Not a security feature, just sanity.)&lt;/span&gt;
setopt INC_APPEND_HISTORY

&lt;span class="c"&gt;# Keep history size reasonable&lt;/span&gt;
&lt;span class="nv"&gt;HISTSIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000
&lt;span class="nv"&gt;SAVEHIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000

&lt;span class="c"&gt;# Lock down history file permissions&lt;/span&gt;
&lt;span class="nv"&gt;HISTFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HISTFILE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.zsh_history&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HISTFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two opinions here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do not enable &lt;code&gt;SHARE_HISTORY&lt;/code&gt;&lt;/strong&gt; unless you understand the tradeoff. Shared history across sessions is convenient, but it expands how quickly a bad paste propagates.&lt;/li&gt;
&lt;li&gt;A lot of “zsh security snippets” on the internet are cargo cult. Your goal is not to collect &lt;code&gt;setopt&lt;/code&gt;s. Your goal is to make the risky path slightly annoying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want one extra guardrail, add a “are you sure?” alias for the worst offenders:&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="nb"&gt;alias export&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'echo "Don'&lt;/span&gt;t &lt;span class="nb"&gt;export &lt;/span&gt;secrets globally. Use per-command &lt;span class="nb"&gt;env &lt;/span&gt;vars.&lt;span class="s2"&gt;"; export'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s not bulletproof. It’s a speed bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fish: private mode/history settings and safe patterns
&lt;/h2&gt;

&lt;p&gt;Fish does you a favour here: it has a clear private mode.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start a private session:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fish &lt;span class="nt"&gt;--private&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In private mode, fish won’t write new history to disk. That’s exactly what you want when you’re about to test credentials, debug auth, or do anything you don’t want hanging around.&lt;/p&gt;

&lt;p&gt;In non-private fish sessions, the “safe pattern” is less about history and more about not passing secrets on the command line in the first place. Which brings us to the thing most posts ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment variable scoping: avoid exporting globally; use per-command/env wrappers
&lt;/h2&gt;

&lt;p&gt;“Just put it in an environment variable” is one of the most misleading pieces of security advice in dev tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does putting a secret in an environment variable keep it safe?&lt;/strong&gt; No. It changes the exposure surface.&lt;/p&gt;

&lt;p&gt;Here’s what environment variables do (and why they bite you):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They are inherited by child processes by default.&lt;/li&gt;
&lt;li&gt;They’re often visible to the same user via process inspection tooling.&lt;/li&gt;
&lt;li&gt;They get copied into crash reports and debugging output more often than you’d like.&lt;/li&gt;
&lt;li&gt;They get written into CI logs if you echo them, print env dumps, or run tools with &lt;code&gt;--verbose&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So what’s better?&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Prefer per-command environment variables
&lt;/h3&gt;

&lt;p&gt;Instead of:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...    &lt;span class="c"&gt;# persists in your shell session&lt;/span&gt;
my-cli &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="nt"&gt;-stuff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... my-cli &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="nt"&gt;-stuff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That scopes the secret to that process and its children. It’s still not “secure”, but it’s &lt;strong&gt;shorter-lived&lt;/strong&gt; and less likely to leak via “I forgot I exported that 4 hours ago.”&lt;/p&gt;

&lt;p&gt;This is also the habit that pairs well with the leading-space history rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt; &lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... my-cli &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="nt"&gt;-stuff&lt;/span&gt;
&lt;span class="c"&gt;# ^ leading space keeps it out of history (bash/zsh with the right settings)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Prefer stdin or files over argv
&lt;/h3&gt;

&lt;p&gt;If a tool supports reading a token from stdin or a file, take it. Command-line arguments are the worst place for secrets because they get logged, copied, and pasted.&lt;/p&gt;

&lt;p&gt;Concrete example: if you must call &lt;code&gt;curl&lt;/code&gt;, don’t inline the token. Put it in a header file or use an env var:&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;# Better than: curl -H "Authorization: Bearer ..."&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.example.com/me
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still not perfect, but it avoids the “token is literally in the command string” scenario that gets screenshotted.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Reduce lifetime: unset aggressively
&lt;/h3&gt;

&lt;p&gt;If you did export something, clean up:&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="nb"&gt;unset &lt;/span&gt;OPENAI_API_KEY AWS_SECRET_ACCESS_KEY GITHUB_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make this muscle memory. The difference between “secret existed for 30 seconds” and “secret existed for 3 days” is huge when you’re running random CLIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dotfiles hygiene: avoid committing secrets; .gitignore patterns; separate local/private files
&lt;/h2&gt;

&lt;p&gt;Dotfiles are where secrets go to die.&lt;/p&gt;

&lt;p&gt;The “I’ll just add it to my &lt;code&gt;~/.zshrc&lt;/code&gt; temporarily” move is how keys end up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in a dotfiles repo&lt;/li&gt;
&lt;li&gt;in a gist&lt;/li&gt;
&lt;li&gt;in a coworker’s PR review&lt;/li&gt;
&lt;li&gt;in a laptop migration tarball&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My rule: &lt;strong&gt;dotfiles should contain loaders, not secrets&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Use patterns like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep local secrets in &lt;code&gt;~/.config/&amp;lt;tool&amp;gt;/secrets.env&lt;/code&gt; or &lt;code&gt;~/.secrets/&amp;lt;project&amp;gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;source them from your shell config, but never check them into git&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example pattern:&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;# ~/.bashrc or ~/.zshrc&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.config/secrets/global.env"&lt;/span&gt; &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;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
  &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.config/secrets/global.env"&lt;/span&gt;
  &lt;span class="nb"&gt;set&lt;/span&gt; +a
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in your dotfiles repo, be aggressive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Local secrets
**/*.env
**/*.env.*
.envrc
.env.local
*.pem
*.key
*.p12
*.pfx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also where I’ll plug a workflow stance: if you’re doing a lot of terminal-based AI tooling, treat your terminal like a log sink. I wrote a dedicated guide on &lt;a href="https://dev.to/blog/ai-security-complete-guide"&gt;AI security&lt;/a&gt; because these “small” leaks compound when you start using &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; that touch multiple systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use env managers: direnv workflow for per-directory secrets; mise patterns for tasks/env
&lt;/h2&gt;

&lt;p&gt;This is the boring answer that is actually the right one: &lt;strong&gt;use per-directory environment loading&lt;/strong&gt; so secrets exist only inside the project context.&lt;/p&gt;

&lt;h3&gt;
  
  
  direnv: local secrets that load on &lt;code&gt;cd&lt;/code&gt; and unload on exit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;direnv&lt;/code&gt; is an extension for your shell that can load and unload environment variables based on the current directory (&lt;a href="https://direnv.net/" rel="noopener noreferrer"&gt;direnv&lt;/a&gt;). It works with bash, zsh, and fish.&lt;/p&gt;

&lt;p&gt;The pattern I recommend:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;.envrc&lt;/code&gt; to your repo (but keep it non-secret)&lt;/li&gt;
&lt;li&gt;Store real secrets in &lt;code&gt;.env.local&lt;/code&gt; (gitignored)&lt;/li&gt;
&lt;li&gt;Have &lt;code&gt;.envrc&lt;/code&gt; load &lt;code&gt;.env.local&lt;/code&gt; via &lt;code&gt;dotenv&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example:&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;# .envrc (checked in)&lt;/span&gt;
dotenv_if_exists .env.local

&lt;span class="c"&gt;# Optional: enforce a minimum set of vars&lt;/span&gt;
: &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;:?OPENAI_API_KEY&lt;span class="p"&gt; missing&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create &lt;code&gt;.env.local&lt;/code&gt; (never commit it):&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;# .env.local (gitignored)&lt;/span&gt;
&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authorize once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;direnv allow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The security feature here is not “dotenv is magic”. The security feature is: &lt;strong&gt;when you leave the directory, the variables are unloaded&lt;/strong&gt;. That reduces accidental reuse.&lt;/p&gt;

&lt;p&gt;If you’re building agentic CLIs, pair this with log redaction. I’ve gone deep on that in &lt;a href="https://dev.to/blog/redact-secrets-ai-cli"&gt;redacting secrets in an AI coding CLI tool&lt;/a&gt;, because agents love printing things you wish they didn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  mise: define env and tasks together, but keep secrets out of &lt;code&gt;mise.toml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mise&lt;/code&gt; (mise-en-place) is a “tools + env + tasks” manager. It can load per-project environment variables and read from local env files (&lt;a href="https://mise.jdx.dev/" rel="noopener noreferrer"&gt;mise&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The safe pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;check in &lt;code&gt;mise.toml&lt;/code&gt; with &lt;strong&gt;non-secret&lt;/strong&gt; defaults&lt;/li&gt;
&lt;li&gt;read secrets from a local file like &lt;code&gt;.env.local&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;mise&lt;/code&gt; even shows this idea right in its docs examples: it can load env vars “from &lt;code&gt;.env.local&lt;/code&gt;” via &lt;code&gt;_.file = ".env.local"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# mise.toml (checked in)&lt;/span&gt;
&lt;span class="nn"&gt;[env]&lt;/span&gt;
&lt;span class="c"&gt;# non-secret defaults&lt;/span&gt;
&lt;span class="py"&gt;API_BASE_URL&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com"&lt;/span&gt;
&lt;span class="c"&gt;# load secrets from a local file that is gitignored&lt;/span&gt;
&lt;span class="py"&gt;_.file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;".env.local"&lt;/span&gt;

&lt;span class="nn"&gt;[tasks.dev]&lt;/span&gt;
&lt;span class="py"&gt;run&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"npm run dev"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;.env.local&lt;/code&gt; is the same as above, and you’ve got tasks that run with the right env without teaching every dev to &lt;code&gt;export&lt;/code&gt; things manually.&lt;/p&gt;

&lt;p&gt;If you want to go further, I’ve already written about using both together in &lt;a href="https://dev.to/blog/reproducible-terminal-dev-environment"&gt;direnv + mise for a reproducible terminal dev environment&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add secret scanning: pre-commit + repo scanners; GitHub secret scanning/push protection
&lt;/h2&gt;

&lt;p&gt;You need two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local&lt;/strong&gt;: stop leaks before they hit git&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote&lt;/strong&gt;: stop leaks before they hit &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Local scanning: gitleaks and trufflehog
&lt;/h3&gt;

&lt;p&gt;I’m not going to pretend regex scanning is perfect. It’s still one of the highest ROI controls you can add in a day.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gitleaks&lt;/code&gt; is a popular open-source secret scanner (&lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;gitleaks&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;trufflehog&lt;/code&gt; is another widely used tool that can also verify certain credentials (&lt;a href="https://github.com/trufflesecurity/trufflehog" rel="noopener noreferrer"&gt;TruffleHog&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow I like is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run &lt;code&gt;gitleaks&lt;/code&gt; (fast) on pre-commit&lt;/li&gt;
&lt;li&gt;run &lt;code&gt;trufflehog&lt;/code&gt; (deeper) in CI on PRs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want a step-by-step setup, I already published &lt;a href="https://dev.to/blog/gitleaks-pre-commit-ci-setup"&gt;gitleaks + pre-commit + CI&lt;/a&gt;. Hooking scanners into your normal dev loop beats “quarterly security reminders” every time.&lt;/p&gt;

&lt;p&gt;Also: if you’re adopting agentic tooling or “vibe coding” workflows, do not trust that logs won’t capture secrets. Put this in your &lt;a href="https://dev.to/pillars/production-ai"&gt;AI in production&lt;/a&gt; checklist right next to observability and rate limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Remote scanning: GitHub Secret Protection and push protection
&lt;/h3&gt;

&lt;p&gt;GitHub’s secret scanning exists because “we’ll catch it in code review” doesn’t work.&lt;/p&gt;

&lt;p&gt;Here’s the official intro video from GitHub:&lt;/p&gt;

&lt;p&gt;[YOUTUBE:vMhDkt5JNN0|Introduction to secret leaks and getting started with GitHub Secret Protection]&lt;/p&gt;

&lt;p&gt;Enable secret scanning for repos where it’s available, and if you can, enable &lt;strong&gt;push protection&lt;/strong&gt;. The goal is simple: make it hard to push a key even if someone tries.&lt;/p&gt;

&lt;p&gt;If your team is already living on GitHub, this is a no-brainer control. It’s the closest thing to a seatbelt you can add without changing developer behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a key leaks: rotate/revoke, scrub history, scrub git history, and notify/log review
&lt;/h2&gt;

&lt;p&gt;Leaks happen. The only unacceptable move is “hope nobody noticed.”&lt;/p&gt;

&lt;p&gt;Here’s the runbook I want you to follow the minute an API key is exposed in a terminal, repo, or log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Revoke or rotate immediately
&lt;/h3&gt;

&lt;p&gt;Do this first. Not after cleanup.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rotate the API key at the provider.&lt;/li&gt;
&lt;li&gt;If it’s a cloud key, invalidate sessions/credentials where possible.&lt;/li&gt;
&lt;li&gt;If it’s scoped (good), rotate only that scope. If it’s broad (bad), assume blast radius.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Time matters. If a secret hit a public repo for even &lt;strong&gt;1 minute&lt;/strong&gt;, you should assume it’s compromised.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Figure out where it leaked
&lt;/h3&gt;

&lt;p&gt;You need to know if this is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;terminal-only&lt;/strong&gt; (history / screenshot / scrollback)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;git&lt;/strong&gt; (committed, PR, or pushed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI logs&lt;/strong&gt; (workflow output)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;issue trackers / chat&lt;/strong&gt; (Slack, Jira)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each has different cleanup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Scrub shell history locally
&lt;/h3&gt;

&lt;p&gt;If the leak was “I typed it”, you have to clean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your history file (&lt;code&gt;~/.zsh_history&lt;/code&gt;, &lt;code&gt;~/.bash_history&lt;/code&gt;, fish history)&lt;/li&gt;
&lt;li&gt;your terminal scrollback (some terminals persist it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practically: open the history file, remove the line(s), and consider truncating.&lt;/p&gt;

&lt;p&gt;Also: if you used &lt;code&gt;export SOME_KEY=...&lt;/code&gt;, search your dotfiles for it. People forget they set it in two places.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: If it hit git, rewrite history (properly)
&lt;/h3&gt;

&lt;p&gt;Removing a secret from the current HEAD is not enough. You must remove it from history.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;git filter-repo&lt;/code&gt; (the modern replacement for &lt;code&gt;filter-branch&lt;/code&gt;) (&lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;Elijah Newren&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;After rewriting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;force-push the rewritten history&lt;/li&gt;
&lt;li&gt;rotate the secret again (assume it was copied)&lt;/li&gt;
&lt;li&gt;invalidate old clones if you can (hard in practice)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this feels extreme, good. It should. You’re paying down an incident.&lt;/p&gt;

&lt;p&gt;For a safer day-to-day git workflow (which reduces how often you’re doing history surgery), I’ve also got a guide on &lt;a href="https://dev.to/blog/advanced-git-commands-aliases-rewrite"&gt;advanced Git commands safely&lt;/a&gt; and a migration guide for &lt;a href="https://dev.to/blog/jj-version-control-git-compatible"&gt;jj version control&lt;/a&gt; if you’re experimenting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Prevent reintroduction
&lt;/h3&gt;

&lt;p&gt;This is where most teams fail. They clean up and move on.&lt;/p&gt;

&lt;p&gt;Do these within &lt;strong&gt;24 hours&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enable GitHub secret scanning / push protection where possible&lt;/li&gt;
&lt;li&gt;add &lt;code&gt;gitleaks&lt;/code&gt;/&lt;code&gt;trufflehog&lt;/code&gt; in CI&lt;/li&gt;
&lt;li&gt;add a pre-commit hook&lt;/li&gt;
&lt;li&gt;add &lt;code&gt;.env.local&lt;/code&gt; and &lt;code&gt;.envrc&lt;/code&gt; patterns to &lt;code&gt;.gitignore&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;document the &lt;code&gt;direnv&lt;/code&gt;/&lt;code&gt;mise&lt;/code&gt; workflow in your repo README&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re using agentic CLIs, also harden logging and redaction. This intersects with &lt;a href="https://dev.to/pillars/llm-security"&gt;LLM security&lt;/a&gt; and &lt;a href="https://dev.to/pillars/ai-security"&gt;AI security&lt;/a&gt; more than most people want to admit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Audit
&lt;/h3&gt;

&lt;p&gt;Even on small teams, do a minimal audit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;check provider access logs for the time window (last &lt;strong&gt;24 hours&lt;/strong&gt; at minimum)&lt;/li&gt;
&lt;li&gt;check GitHub audit/security logs if the leak was in a repo&lt;/li&gt;
&lt;li&gt;search Slack/Jira for the token prefix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You’re not hunting for perfection. You’re trying to answer: “Did anyone use this?”&lt;/p&gt;

&lt;h2&gt;
  
  
  The safest way to provide API keys to CLI tools locally (my default)
&lt;/h2&gt;

&lt;p&gt;If you want my default workflow in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;direnv&lt;/code&gt; for per-project env loading.&lt;/li&gt;
&lt;li&gt;Store secrets in &lt;code&gt;.env.local&lt;/code&gt; (gitignored).&lt;/li&gt;
&lt;li&gt;For one-off commands, use &lt;code&gt;VAR=... cmd&lt;/code&gt; with a leading space.&lt;/li&gt;
&lt;li&gt;Run scanners locally and in CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you do that, you’ve made the dangerous path annoying and the safe path easy.&lt;/p&gt;

&lt;p&gt;If you’re building internal tooling, here’s my challenge: &lt;strong&gt;make “paste the key into the terminal” the worst UX&lt;/strong&gt;. Accept stdin. Accept files. Integrate with a secrets manager. Your future self will thank you.&lt;/p&gt;

&lt;p&gt;My prediction: as agentic CLIs become normal, the next wave of “secret leaks” won’t come from humans fat-fingering &lt;code&gt;curl&lt;/code&gt;. It’ll come from tools that log too much. If you’re not treating logs as an attack surface, you’re already behind.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/prevent-api-key-leaks-shell-history?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=prevent-api-key-leaks-shell-history" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>secrets</category>
      <category>cli</category>
      <category>zsh</category>
      <category>bash</category>
    </item>
    <item>
      <title>How to Set Up a Local AI Hub for Home Automation Privacy [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Fri, 04 Sep 2026 12:44:07 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-set-up-a-local-ai-hub-for-home-automation-privacy-2026-1o9d</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-set-up-a-local-ai-hub-for-home-automation-privacy-2026-1o9d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/local-ai-hub-privacy-setup" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want a local AI hub to run home automation agents without cloud data sharing, you need two things: &lt;strong&gt;network control&lt;/strong&gt; and &lt;strong&gt;proof&lt;/strong&gt;. The prerequisite that trips people up is simple. If your hub sits on the same flat LAN as everything else, you can’t tell “local AI” from “quiet telemetry.”&lt;/p&gt;

&lt;p&gt;This tutorial is a &lt;strong&gt;local ai hub home automation privacy setup&lt;/strong&gt; you can actually verify. We’ll map what likely runs locally vs. what tends to reach the cloud (setup, updates, voice/LLM, remote access). Then we’ll segment your LAN so the hub can talk to devices but not the internet. Finally, we’ll validate outbound behavior three ways: &lt;strong&gt;DNS logs&lt;/strong&gt;, &lt;strong&gt;packet capture/SNI&lt;/strong&gt;, and &lt;strong&gt;firewall deny logs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;UGREEN is pitching HomeAgent as an “agentic AI local hub” that unifies local AI compute and storage. Cool. But I don’t trust marketing copy for privacy claims. I trust packet captures.&lt;/p&gt;

&lt;p&gt;Here’s the official product framing if you want to see what UGREEN is promising: &lt;a href="https://www.youtube.com/watch?v=uGEjWA9TES0" rel="noopener noreferrer"&gt;UGREEN Official&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a local AI hub?
&lt;/h2&gt;

&lt;p&gt;A local AI hub is a home device that runs automation logic and AI-assisted behaviors on your own network, ideally performing inference and orchestration locally instead of sending your smart-home data to a vendor cloud.&lt;/p&gt;

&lt;p&gt;In practice, “local” can mean at least &lt;strong&gt;three different layers&lt;/strong&gt;, and each layer can have different cloud dependencies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hub firmware + core services&lt;/strong&gt;: device discovery, device control, local storage, and whatever “agent runtime” the hub runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile app onboarding + account flow&lt;/strong&gt;: pairing, Wi‑Fi provisioning, device registration, push notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI/voice pipeline&lt;/strong&gt;: speech-to-text, intent classification, LLM inference, tool calling, and TTS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A hub can be local in layer 1 and still leak in layers 2 or 3.&lt;/p&gt;

&lt;p&gt;If you’re new to the “agent” vocabulary being used in smart homes right now, skim my &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; pillar. Smart-home hubs are basically shipping consumer-grade &lt;strong&gt;agent orchestration&lt;/strong&gt; with nicer packaging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Map what runs local vs what might still hit cloud
&lt;/h2&gt;

&lt;p&gt;When a vendor says “local AI,” I translate it into a checklist of the usual escape hatches. You’re not being cynical. You’re being accurate.&lt;/p&gt;

&lt;p&gt;[YOUTUBE:uGEjWA9TES0|Introducing UGREEN HomeAgent]&lt;/p&gt;

&lt;h3&gt;
  
  
  What should be able to run locally (and how to test it)
&lt;/h3&gt;

&lt;p&gt;If the product is serious about local-first, this stuff should keep working even if your ISP is having a bad day.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automations and device control&lt;/strong&gt;: lights, switches, scenes, schedules. This should keep working with WAN unplugged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local integrations&lt;/strong&gt;: MQTT, local HTTP, LAN discovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local storage&lt;/strong&gt;: if the hub “unifies compute and storage,” recordings and logs shouldn’t evaporate the moment the internet drops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete test: after setup, you should be able to flip your WAN off for &lt;strong&gt;30 minutes&lt;/strong&gt; and see &lt;strong&gt;zero functional degradation&lt;/strong&gt; for local automations.&lt;/p&gt;

&lt;h3&gt;
  
  
  What commonly requires cloud (even on “local-first” products)
&lt;/h3&gt;

&lt;p&gt;This is where most “local” pitches quietly fall apart.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First-time onboarding&lt;/strong&gt;: lots of products insist on account creation or cloud registration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firmware updates&lt;/strong&gt;: almost always cloud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote access&lt;/strong&gt;: unless you self-host a VPN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice/LLM inference&lt;/strong&gt;: the big one. Some hubs do a local wake word, then ship your audio or text off to a cloud STT/LLM anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Home Assistant is a good baseline because it’s honest about the split. The core runs locally. Optional cloud features add remote access and convenience. Nabu Casa literally sells that tradeoff on &lt;a href="https://www.nabucasa.com/" rel="noopener noreferrer"&gt;Home Assistant Cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That “map what runs where” lens is also how I think about real production AI systems. Same discipline at home. List dependencies. Figure out what’s critical. Put the rest on a leash. My post on &lt;a href="https://dev.to/glossary/ai-in-production"&gt;AI in production&lt;/a&gt; is the enterprise version of this mental model.&lt;/p&gt;

&lt;h3&gt;
  
  
  The privacy stance I recommend
&lt;/h3&gt;

&lt;p&gt;Default position: &lt;strong&gt;assume everything wants to phone home&lt;/strong&gt; until you can prove it doesn’t.&lt;/p&gt;

&lt;p&gt;That’s not paranoia. It’s one of those things where the boring answer is actually the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Segment your LAN (so local traffic works, internet doesn’t)
&lt;/h2&gt;

&lt;p&gt;If you keep your hub on the same network as your laptops, it will have accidental access to way more than it needs. Segmentation is blast-radius control. It’s also the only way your logs mean anything.&lt;/p&gt;

&lt;p&gt;The target architecture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LAN (trusted)&lt;/strong&gt;: laptops, phones, servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IoT VLAN (untrusted)&lt;/strong&gt;: switches, bulbs, cameras, smart speakers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hub VLAN (semi-trusted)&lt;/strong&gt;: the “local AI hub” itself. It’s more capable than a bulb, but less trustworthy than your workstation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only do one thing, do this. Put the hub on its own VLAN and block outbound internet by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimal addressing plan
&lt;/h3&gt;

&lt;p&gt;Keep it boring. Boring networks are debuggable networks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LAN: &lt;code&gt;192.168.10.0/24&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IoT: &lt;code&gt;192.168.20.0/24&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Hub: &lt;code&gt;192.168.30.0/24&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three subnets. Easy to reason about. Enough separation to actually see what’s going on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why “Hub VLAN” is worth it
&lt;/h3&gt;

&lt;p&gt;A local AI hub is not a dumb endpoint. It’s a small server with storage and an agent runtime. Treat it like any other box that can run a &lt;strong&gt;large language model&lt;/strong&gt; in a tool loop.&lt;/p&gt;

&lt;p&gt;If you’ve built agents, you already know why this matters. If you haven’t, here’s the smart-home translation: don’t run your security cameras on the same network as your work laptop.&lt;/p&gt;

&lt;p&gt;For more on how I think about agent attack surfaces, see &lt;a href="https://dev.to/pillars/ai-security"&gt;AI security&lt;/a&gt; and the very real threat of &lt;a href="https://dev.to/blog/prompt-injection-2026-owasp-llm-vulnerability"&gt;prompt injection&lt;/a&gt; in any system that turns “natural language commands” into actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: pfSense rules that keep discovery working (without “allow any”)
&lt;/h2&gt;

&lt;p&gt;Most IoT segmentation guides fail at the most predictable moment.&lt;/p&gt;

&lt;p&gt;They isolate things, discovery breaks, then they add an “allow any” rule because they just want it to work. Congratulations. You now have a flat network again. You just made it harder to see what’s leaking.&lt;/p&gt;

&lt;p&gt;Don’t do that.&lt;/p&gt;

&lt;p&gt;Use a stateful firewall and be explicit. pfSense is a solid choice and the docs are genuinely good: &lt;a href="https://docs.netgate.com/pfsense/en/latest/" rel="noopener noreferrer"&gt;pfSense Documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The baseline policy
&lt;/h3&gt;

&lt;p&gt;For both IoT VLAN and Hub VLAN:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Allow to local DNS&lt;/strong&gt; (your resolver, e.g. Pi-hole)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow to NTP&lt;/strong&gt; (your firewall or a local NTP server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow east-west traffic only where needed&lt;/strong&gt; (Hub ↔ IoT)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block everything else to WAN&lt;/strong&gt; with logging enabled&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point #4 is the whole tutorial. “Default deny” plus logs is what turns privacy into something you can audit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The “things that usually break” allowlist
&lt;/h3&gt;

&lt;p&gt;You’ll probably need a subset of these. The right list depends on your stack, but the failure modes are weirdly consistent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS&lt;/strong&gt;: UDP/TCP &lt;code&gt;53&lt;/code&gt; to your Pi-hole (or Unbound)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NTP&lt;/strong&gt;: UDP &lt;code&gt;123&lt;/code&gt; (prefer a local NTP server. Don’t let every device hit random pool servers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mDNS&lt;/strong&gt;: UDP &lt;code&gt;5353&lt;/code&gt; (multicast discovery)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSDP/UPnP discovery&lt;/strong&gt;: UDP &lt;code&gt;1900&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MQTT&lt;/strong&gt; (if you use it): TCP &lt;code&gt;1883&lt;/code&gt; (or &lt;code&gt;8883&lt;/code&gt; for TLS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Home Assistant&lt;/strong&gt;: typically TCP &lt;code&gt;8123&lt;/code&gt; (if your hub talks to HA)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matter&lt;/strong&gt;: often UDP &lt;code&gt;5540&lt;/code&gt; (plus multicast behavior)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s already seven concrete items. The move is not “open these to the internet.” The move is “open these inside your LAN segments, and nowhere else.”&lt;/p&gt;

&lt;p&gt;If you’re running a local voice stack or any &lt;a href="https://dev.to/glossary/local-ai"&gt;local AI&lt;/a&gt; components, you’ll likely also have internal HTTP ports between services. Keep them internal. Always.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical pfSense pattern
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Create &lt;strong&gt;aliases&lt;/strong&gt;: &lt;code&gt;IOT_NET&lt;/code&gt;, &lt;code&gt;HUB_NET&lt;/code&gt;, &lt;code&gt;DNS_SERVER&lt;/code&gt;, &lt;code&gt;NTP_SERVER&lt;/code&gt;, &lt;code&gt;HA_SERVER&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add rules on the VLAN interface tabs, in this order:

&lt;ol&gt;
&lt;li&gt;Pass DNS to &lt;code&gt;DNS_SERVER&lt;/code&gt; (log)&lt;/li&gt;
&lt;li&gt;Pass NTP to &lt;code&gt;NTP_SERVER&lt;/code&gt; (log)&lt;/li&gt;
&lt;li&gt;Pass Hub ↔ IoT on required ports (log)&lt;/li&gt;
&lt;li&gt;Block any to WAN (log)&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make the rules noisy at first. Logs are data.&lt;/p&gt;

&lt;p&gt;If you want a general mental model for doing “restrict egress, then measure what breaks,” it’s the same loop I use when I build deterministic gates in this site’s multi-agent publishing pipeline. Operating that pipeline (261+ posts shipped), I learned the hard way that &lt;strong&gt;deterministic gates catch more issues than just upgrading to a bigger model&lt;/strong&gt;. Same deal here. A deterministic network gate beats “trust me bro” privacy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Verify outbound traffic (DNS logs → SNI/pcap → firewall logs)
&lt;/h2&gt;

&lt;p&gt;This is the repeatable workflow most guides skip.&lt;/p&gt;

&lt;p&gt;You’re going to prove what the hub is attempting, even if those attempts get blocked.&lt;/p&gt;

&lt;h3&gt;
  
  
  4A. DNS query logs (Pi-hole)
&lt;/h3&gt;

&lt;p&gt;DNS is the easiest audit trail because almost everything starts with a hostname.&lt;/p&gt;

&lt;p&gt;Pi-hole is literally built for this. It’s a “DNS sinkhole” with a dashboard and query logging: &lt;a href="https://docs.pi-hole.net/" rel="noopener noreferrer"&gt;Pi-hole documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What to do:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make Pi-hole the &lt;strong&gt;only&lt;/strong&gt; DNS server your Hub VLAN can reach.&lt;/li&gt;
&lt;li&gt;Turn on query logging.&lt;/li&gt;
&lt;li&gt;Reboot the hub. Then do a “normal” day. Open the app, trigger an automation, try voice.&lt;/li&gt;
&lt;li&gt;Export or screenshot the query log filtered by the hub’s IP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you’re looking for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vendor domains you expect (update CDN, auth)&lt;/li&gt;
&lt;li&gt;Vendor domains you don’t expect (analytics, crash reporting)&lt;/li&gt;
&lt;li&gt;Third-party telemetry (common: &lt;code&gt;*.amazonaws.com&lt;/code&gt;, &lt;code&gt;*.cloudfront.net&lt;/code&gt;, &lt;code&gt;*.googleapis.com&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if you can’t interpret every domain, you can count. If your “local AI hub” is making &lt;strong&gt;hundreds of DNS lookups/day&lt;/strong&gt; to external domains, it’s not behaving like an offline-first controller.&lt;/p&gt;

&lt;p&gt;One more thing. If you suspect the hub is trying to resolve DNS over something else (DoH/DoT), treat that as a red flag. I’ll cover it in pitfalls.&lt;/p&gt;

&lt;h3&gt;
  
  
  4B. Packet capture + SNI with Wireshark/tcpdump
&lt;/h3&gt;

&lt;p&gt;DNS tells you names. Packet capture tells you where the traffic actually goes.&lt;/p&gt;

&lt;p&gt;Wireshark’s display filter reference is the canonical resource when you’re filtering down to what matters: &lt;a href="https://www.wireshark.org/docs/dfref/" rel="noopener noreferrer"&gt;Wireshark Display Filters&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What to capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mirror the hub’s switch port or capture on pfSense.&lt;/li&gt;
&lt;li&gt;Focus on outbound attempts from the hub IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you can see without decrypting TLS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Destination IPs and ports&lt;/li&gt;
&lt;li&gt;TLS handshake metadata, often including SNI (Server Name Indication)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Useful filters to start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ip.addr == 192.168.30.50&lt;/code&gt; (replace with hub IP)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tls.handshake.extensions_server_name&lt;/code&gt; (to spot SNI)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dns&lt;/code&gt; (to correlate DNS and connections)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quic&lt;/code&gt; (to spot HTTP/3 attempts)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If SNI is present, you can usually map “destination IP” back to a hostname even if DNS is doing something clever.&lt;/p&gt;

&lt;p&gt;This is the point where you stop guessing.&lt;/p&gt;

&lt;h3&gt;
  
  
  4C. Firewall logs (pfSense)
&lt;/h3&gt;

&lt;p&gt;Firewall logs tell you what actually got out and what got blocked.&lt;/p&gt;

&lt;p&gt;With “block to WAN” logging enabled, you get an auditable list of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src IP&lt;/code&gt; (hub)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dst IP&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dst port&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;timestamps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you see repeated blocks to &lt;code&gt;443&lt;/code&gt; every &lt;strong&gt;10 seconds&lt;/strong&gt; right after boot, that’s usually a cloud dependency or a telemetry retry loop.&lt;/p&gt;

&lt;p&gt;This is also the safest way to validate “it stays local” because you’re enforcing it, not just watching it.&lt;/p&gt;

&lt;p&gt;If you want the same thinking applied to software agents, my post on &lt;a href="https://dev.to/glossary/agent-framework"&gt;agent framework&lt;/a&gt; selection and failure modes is the software version of this: don’t trust the happy-path demo. Build a harness. Watch what breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Offline acceptance test (prove it still works when WAN is down)
&lt;/h2&gt;

&lt;p&gt;You don’t have “local-first.” You have “local-ish until the ISP sneezes” unless you test it.&lt;/p&gt;

&lt;p&gt;Here’s the checklist I use.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Baseline:&lt;/strong&gt; with WAN up, confirm the hub works for: 3 automations, 1 device discovery flow, and 1 voice/agent command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cut WAN:&lt;/strong&gt; physically unplug WAN or disable it on your router for &lt;strong&gt;20 minutes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power cycle:&lt;/strong&gt; reboot the hub while WAN is down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run the same 5 tasks again.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your logs:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Pi-hole: do DNS queries spike?&lt;/li&gt;
&lt;li&gt;pfSense: do you see repeated blocked attempts to the same IPs/domains?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore WAN:&lt;/strong&gt; re-enable WAN and verify the hub doesn’t “catch up” by dumping a backlog of telemetry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step #3 is the money step. Reboots are where devices love to register, phone home, and “validate licenses.”&lt;/p&gt;

&lt;p&gt;If you’re building home automation local voice assistant without cloud, you can validate the voice pipeline separately too. I’ve got a full stack write-up here: &lt;a href="https://dev.to/blog/local-ai-voice-assistant-whisper-piper-ollama"&gt;local AI voice assistant&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Firmware updates without turning your privacy model into Swiss cheese
&lt;/h2&gt;

&lt;p&gt;Firmware updates are the most legitimate reason a local hub needs internet.&lt;/p&gt;

&lt;p&gt;You still don’t have to leave the door open all day.&lt;/p&gt;

&lt;p&gt;My recommended process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a &lt;strong&gt;temporary “update window” rule&lt;/strong&gt;: allow Hub VLAN egress to WAN for &lt;strong&gt;60 minutes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Schedule it. Don’t leave it on.&lt;/li&gt;
&lt;li&gt;During the window:

&lt;ul&gt;
&lt;li&gt;Watch Pi-hole queries.&lt;/li&gt;
&lt;li&gt;Watch pfSense states and logs.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;After the window:

&lt;ul&gt;
&lt;li&gt;Disable the rule.&lt;/li&gt;
&lt;li&gt;Reboot the hub once. Confirm it still functions offline.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your hub breaks when you block internet again, that’s not a “privacy device.” That’s a cloud device with a local cache.&lt;/p&gt;

&lt;p&gt;This is the same pattern I use for sensitive workflows in my own automation. One hard lesson from operating my GSC-driven SEO automation is that &lt;strong&gt;changes need isolation windows&lt;/strong&gt; to attribute impact. Networking is the same. Make changes in controlled windows, observe, then lock it back down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Common pitfalls (and how to mitigate them)
&lt;/h2&gt;

&lt;p&gt;These are the ways a “local-only” plan fails in real homes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardcoded DNS (device ignores your resolver)
&lt;/h3&gt;

&lt;p&gt;Symptom: Pi-hole logs show nothing, but the hub still reaches the internet.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Block outbound DNS (&lt;code&gt;53&lt;/code&gt;) to anywhere except your resolver.&lt;/li&gt;
&lt;li&gt;Add NAT redirection rules. Force all &lt;code&gt;53&lt;/code&gt; to Pi-hole.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  DoH/DoT (DNS over HTTPS/TLS)
&lt;/h3&gt;

&lt;p&gt;Symptom: outbound &lt;code&gt;443&lt;/code&gt; to known DoH endpoints, and no readable DNS.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Block known DoH endpoints.&lt;/li&gt;
&lt;li&gt;Prefer allowlisting outbound rather than trying to maintain a denylist forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  QUIC/HTTP3 hides some hostname cues
&lt;/h3&gt;

&lt;p&gt;Symptom: lots of UDP &lt;code&gt;443&lt;/code&gt; traffic (QUIC). Harder to inspect.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Block UDP &lt;code&gt;443&lt;/code&gt; from Hub VLAN and IoT VLAN.&lt;/li&gt;
&lt;li&gt;Force fallback to TCP &lt;code&gt;443&lt;/code&gt; where SNI and logging tend to be easier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to go deep on this, I wrote a full playbook on &lt;a href="https://dev.to/blog/debug-http3-quic-production"&gt;QUIC&lt;/a&gt; debugging. Same protocol. Same pain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Certificate pinning (TLS inspection won’t help)
&lt;/h3&gt;

&lt;p&gt;Symptom: you can’t MITM even if you wanted to.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don’t rely on TLS inspection for privacy guarantees.&lt;/li&gt;
&lt;li&gt;Rely on DNS logs, destination IPs, and firewall egress controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  “Local AI” that still uses cloud LLMs for the hard part
&lt;/h3&gt;

&lt;p&gt;Symptom: voice works online only, or gets dramatically slower offline.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Force an offline test.&lt;/li&gt;
&lt;li&gt;If it’s cloud-backed, decide intentionally. Sometimes the right answer is to run your own &lt;a href="https://dev.to/pillars/local-llms"&gt;local LLM&lt;/a&gt; on a box you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Based on the benchmark data I maintain at &lt;a href="https://dev.to/llm-benchmarks"&gt;kunalganglani.com/llm-benchmarks&lt;/a&gt;, small local models can be perfectly usable for intent classification and home-control tool routing. You don’t need frontier-model intelligence to turn on a lamp. You need reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  My prediction: “local AI” will become a networking problem, not an AI problem
&lt;/h2&gt;

&lt;p&gt;Smart-home vendors are going to ship more “agentic AI” hubs because it’s the only way they can sell privacy and speed in 2026 without admitting the cloud business model is getting toxic.&lt;/p&gt;

&lt;p&gt;The winners won’t be the hubs with the fanciest model. They’ll be the ones that make local-only &lt;strong&gt;verifiable&lt;/strong&gt;. Clear dependency maps. Offline-first UX. Admin-grade egress controls.&lt;/p&gt;

&lt;p&gt;Until then, don’t buy the promise. Buy the device, stick it on a VLAN, and make it earn your trust.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/local-ai-hub-privacy-setup?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=local-ai-hub-privacy-setup" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>smarthome</category>
      <category>privacy</category>
      <category>localai</category>
      <category>networking</category>
    </item>
    <item>
      <title>Xbox Cloud Gaming Pay‑As‑You‑Go Latency: Fix Input Lag Fast [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Fri, 04 Sep 2026 00:42:18 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/xbox-cloud-gaming-pay-as-you-go-latency-fix-input-lag-fast-2026-1i68</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/xbox-cloud-gaming-pay-as-you-go-latency-fix-input-lag-fast-2026-1i68</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/xbox-cloud-gaming-latency" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Xbox Cloud Gaming pay‑as‑you‑go latency is the end‑to‑end delay between your button press and the pixels changing on your screen during an xCloud session. If you’re paying per hour, every “why does this feel mushy?” minute is literally billable waste, and that’s a brutal way to discover your home network has jitter or bufferbloat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your cloud stream looks sharp but controls feel late, you’re usually fighting latency and jitter, not bandwidth.&lt;/li&gt;
&lt;li&gt;Ethernet is the boring answer, but good 5 GHz or 6 GHz Wi‑Fi can get you close if you control interference and signal strength.&lt;/li&gt;
&lt;li&gt;Bufferbloat is the silent killer. SQM (CAKE or &lt;code&gt;fq_codel&lt;/code&gt;) is the most reliable fix I’ve seen for “lag spikes when someone uploads.”&lt;/li&gt;
&lt;li&gt;You should measure baseline ping, &lt;em&gt;loaded&lt;/em&gt; latency, and packet loss before you touch settings, then re-test after each change.&lt;/li&gt;
&lt;li&gt;Some problems are service caps. Codec/bitrate ceilings and server distance are not things you can “tune” at home.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Cloud gaming lives or dies on jitter. Stable 35 ms beats spiky 20 ms every time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The recent reporting around a pay‑as‑you‑go option is what makes this worth caring about now. With a subscription, people tolerate some jank. With per-hour pricing, quality needs to be predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Xbox Cloud Gaming requirements (bandwidth, devices)
&lt;/h2&gt;

&lt;p&gt;Microsoft’s public guidance for Xbox Cloud Gaming tends to emphasize baseline connectivity and supported devices, and you should treat that as the floor, not the target. Bandwidth gets you a picture. Latency, jitter, and packet loss decide whether it feels like a local console.&lt;/p&gt;

&lt;p&gt;A reasonable baseline for most cloud gaming services is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Download throughput:&lt;/strong&gt; 10–20+ Mbps (more headroom helps the encoder adapt without dropping frames)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ping to the internet:&lt;/strong&gt; ideally &lt;strong&gt;&amp;lt; 40 ms&lt;/strong&gt;, acceptable up to &lt;strong&gt;60–80 ms&lt;/strong&gt; depending on game&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packet loss:&lt;/strong&gt; should be &lt;strong&gt;0%&lt;/strong&gt; or close to it. Even &lt;strong&gt;1–2%&lt;/strong&gt; is noticeable on fast action&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wi‑Fi signal:&lt;/strong&gt; if you’re wireless, target an RSSI around &lt;strong&gt;-60 dBm or better&lt;/strong&gt; at the client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn’t to worship any one number. It’s to avoid &lt;em&gt;spikes&lt;/em&gt;. Cloud gaming hates variance.&lt;/p&gt;

&lt;p&gt;If you want the official baseline expectations, start with Microsoft’s Xbox support documentation (it’s JavaScript-rendered, but it’s still the canonical source): &lt;a href="https://support.xbox.com/en-US/help/games-apps/cloud-gaming/about-cloud-gaming" rel="noopener noreferrer"&gt;https://support.xbox.com/en-US/help/games-apps/cloud-gaming/about-cloud-gaming&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What causes input lag in Xbox Cloud Gaming beyond just low bandwidth?
&lt;/h2&gt;

&lt;p&gt;Input lag in cloud gaming is a pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Controller input sampling (Bluetooth can add a few ms vs wired)&lt;/li&gt;
&lt;li&gt;Client processing and buffering&lt;/li&gt;
&lt;li&gt;Uplink to the data center&lt;/li&gt;
&lt;li&gt;Game simulation and render on the server&lt;/li&gt;
&lt;li&gt;Video encode&lt;/li&gt;
&lt;li&gt;Downlink back to you&lt;/li&gt;
&lt;li&gt;Video decode on your device&lt;/li&gt;
&lt;li&gt;Display response (TV processing is often a sleeper culprit)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Even if your ISP gives you &lt;strong&gt;1 Gbps&lt;/strong&gt;, steps 3, 5, 6, 7, and 8 still dominate.&lt;/p&gt;

&lt;p&gt;Here’s a practical mental model I use. For a “feels good” session, you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network RTT&lt;/strong&gt; in the &lt;strong&gt;20–40 ms&lt;/strong&gt; range&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode + decode&lt;/strong&gt; that stays roughly under &lt;strong&gt;20–40 ms&lt;/strong&gt; combined&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Display&lt;/strong&gt; in game mode often around &lt;strong&gt;10–20 ms&lt;/strong&gt; (but many TVs can be worse if game mode is off)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any one stage adds variance, you feel it as mushy aim or late parries.&lt;/p&gt;

&lt;p&gt;I like the way RTINGS talks about separating the chain (display lag vs everything else), even though their page is about TVs: &lt;a href="https://www.rtings.com/tv/tests/inputs/input-lag" rel="noopener noreferrer"&gt;RTINGS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much latency is “playable” for different game genres?
&lt;/h2&gt;

&lt;p&gt;I’m going to be opinionated here. The internet loves pretending you can put a universal number on “playable.” You can’t. But you can put genre buckets on it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn-based / card / tactics:&lt;/strong&gt; You can tolerate &lt;strong&gt;80–120 ms&lt;/strong&gt; and not care much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action RPG / platformers:&lt;/strong&gt; &lt;strong&gt;40–80 ms&lt;/strong&gt; is workable if it’s stable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Racing / fighting / competitive shooters:&lt;/strong&gt; You want &lt;strong&gt;&amp;lt; 40–60 ms&lt;/strong&gt; end-to-end and low jitter, or it starts feeling like you’re playing through syrup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hidden constraint is jitter. A session that bounces between &lt;strong&gt;25 ms&lt;/strong&gt; and &lt;strong&gt;90 ms&lt;/strong&gt; feels worse than a steady &lt;strong&gt;60 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wi‑Fi vs Ethernet for cloud gaming
&lt;/h2&gt;

&lt;p&gt;Ethernet is still the best “setting.” It removes a whole class of problems: interference, contention, and retransmits.&lt;/p&gt;

&lt;p&gt;That said, modern Wi‑Fi can be good enough if you do it intentionally.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Wi‑Fi rule I trust
&lt;/h3&gt;

&lt;p&gt;If your client is on 2.4 GHz, you’re already in the danger zone. 2.4 GHz is crowded, has fewer clean channels, and it’s more likely to deliver micro-spikes that cloud gaming punishes.&lt;/p&gt;

&lt;p&gt;5 GHz is usually the sweet spot. 6 GHz (Wi‑Fi 6E/7) can be even better if both your router and device support it, because there’s typically less congestion. Cisco’s overview is a decent high-level reference for why 6 GHz can reduce interference and improve latency: &lt;a href="https://www.cisco.com/c/en/us/products/wireless/what-is-wi-fi-6.html" rel="noopener noreferrer"&gt;Cisco&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical Wi‑Fi settings that actually matter
&lt;/h3&gt;

&lt;p&gt;If you’re trying to reduce xCloud input lag, these are the knobs that move the needle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Band steering:&lt;/strong&gt; Good in theory, but I often prefer explicitly connecting the gaming device to &lt;strong&gt;5 GHz or 6 GHz&lt;/strong&gt; to avoid “helpful” roaming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel selection:&lt;/strong&gt; Don’t leave it on auto forever. If you live in a condo, scan and pick a cleaner channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel width:&lt;/strong&gt; Wider (80/160 MHz) can be faster, but it can also be less stable in congested areas. Stability beats peak throughput for cloud gaming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DFS channels (5 GHz):&lt;/strong&gt; Can be great if your router supports it and your environment doesn’t trigger radar events. If DFS kicks you off mid-session, you’ll know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mesh backhaul:&lt;/strong&gt; If your “Wi‑Fi” is actually a wireless mesh hop, your latency budget just got worse. Use &lt;strong&gt;wired backhaul&lt;/strong&gt; if you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WMM (Wi‑Fi Multimedia):&lt;/strong&gt; Leave it on. Disabling it often breaks QoS behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick sanity check: if you can, put the gaming device and router in the same room for a test. If the lag disappears, you don’t have an “internet” problem. You have a Wi‑Fi problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  QoS/SQM and bufferbloat fixes (the big win)
&lt;/h2&gt;

&lt;p&gt;Bufferbloat is the undesirable latency that comes from a router or modem buffering too much data. The one-line summary from Bufferbloat.net is basically the story: “Bloated buffers lead to network-crippling latency spikes.” When your upstream is busy, your ping explodes.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://www.bufferbloat.net/projects/bloat/wiki/Introduction/" rel="noopener noreferrer"&gt;Bufferbloat.net&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is why cloud gaming feels fine at 2am and horrible at 7pm. Not because the raw Mbps changed. Because someone in your house started uploading photos, a laptop started a backup, or your phone decided it was iCloud-o’clock.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to do about it: SQM (CAKE / fq_codel)
&lt;/h3&gt;

&lt;p&gt;Smart Queue Management (SQM) is the only consumer-network fix I consistently recommend for real-time apps. It works by shaping traffic so &lt;em&gt;your router&lt;/em&gt; becomes the bottleneck (on purpose), then managing that queue intelligently.&lt;/p&gt;

&lt;p&gt;OpenWrt’s SQM docs are unusually practical about this: measure first, then set shapers slightly below your real line rate so the router, not the ISP gear, controls the queue. Source: &lt;a href="https://openwrt.org/docs/guide-user/network/traffic-shaping/sqm" rel="noopener noreferrer"&gt;OpenWrt SQM documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Concrete starting numbers that work for a lot of people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set download shaper to &lt;strong&gt;90–95%&lt;/strong&gt; of your tested download speed&lt;/li&gt;
&lt;li&gt;Set upload shaper to &lt;strong&gt;85–95%&lt;/strong&gt; of your tested upload speed (upload is often the pain point)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have &lt;strong&gt;300/20 Mbps&lt;/strong&gt;, you might start around &lt;strong&gt;270/18 Mbps&lt;/strong&gt; and iterate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common SQM pitfall
&lt;/h3&gt;

&lt;p&gt;SQM burns CPU. If your router can’t shape at your full bandwidth, you’ll trade bufferbloat for throughput loss. That’s usually a good trade for cloud gaming, but you should be aware of it.&lt;/p&gt;

&lt;p&gt;Also, SQM can conflict with hardware flow offloading. OpenWrt explicitly calls this out.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test/measure latency (baseline vs under load)
&lt;/h2&gt;

&lt;p&gt;If you’re paying per hour, measurement is the whole point. Otherwise you’ll spend money “trying random stuff.”&lt;/p&gt;

&lt;h3&gt;
  
  
  The 15-minute measurement plan
&lt;/h3&gt;

&lt;p&gt;Do this before you tweak anything. Write the numbers down.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Idle ping (baseline):&lt;/strong&gt; Ping a stable target (your ISP gateway if you know it, or a reliable public host). Take the median of &lt;strong&gt;50–100&lt;/strong&gt; pings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter:&lt;/strong&gt; Look at the spread. A tight cluster (e.g., &lt;strong&gt;18–22 ms&lt;/strong&gt;) is good. Spikes to &lt;strong&gt;60+ ms&lt;/strong&gt; are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loaded latency:&lt;/strong&gt; While running a download and an upload (or a speed test), repeat the ping test. This is where bufferbloat shows up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packet loss:&lt;/strong&gt; Even &lt;strong&gt;0.5–1%&lt;/strong&gt; can feel bad. Many tools will report it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a single “loaded latency” grade, Waveform’s bufferbloat test is a decent quick indicator. OpenWrt’s docs link to it in their prep section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measure while actually playing
&lt;/h3&gt;

&lt;p&gt;The hard part is correlating network behavior with “that moment where aiming felt wrong.”&lt;/p&gt;

&lt;p&gt;Practical options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you’re on a PC, run a continuous ping in the background and timestamp spikes.&lt;/li&gt;
&lt;li&gt;Watch your router stats during a session. If you see upload saturation at the exact time input feels delayed, that’s your smoking gun.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don’t need lab gear. You need timestamps and repeatability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service caps vs home network problems (what you can’t fix)
&lt;/h2&gt;

&lt;p&gt;This is the part most cloud gaming guides skip, and it drives people crazy.&lt;/p&gt;

&lt;p&gt;Some problems are on you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wi‑Fi interference&lt;/li&gt;
&lt;li&gt;weak RSSI&lt;/li&gt;
&lt;li&gt;mesh hops&lt;/li&gt;
&lt;li&gt;bufferbloat&lt;/li&gt;
&lt;li&gt;bad router firmware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some problems are on the service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server distance:&lt;/strong&gt; physics is undefeated. If the nearest data center is far, your floor is higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity / congestion:&lt;/strong&gt; if the platform is oversubscribed in your region, you can see stutters that look like “network.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codec/bitrate ceilings:&lt;/strong&gt; many streaming clients cap bitrate or resolution profiles. If the service caps your stream, buying faster internet won’t make the image cleaner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical implication for pay‑as‑you‑go is simple: do a 10-minute test session on a wired connection. If the experience still isn’t good enough, stop. Don’t sink money into a new router hoping it fixes what is really geography.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it your TV/monitor or the network? (isolating display lag)
&lt;/h2&gt;

&lt;p&gt;I’ve seen people blame “latency” when the real culprit was their TV doing image processing.&lt;/p&gt;

&lt;p&gt;Do this isolation sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable Game Mode&lt;/strong&gt; (or equivalent low-latency mode) on your display.&lt;/li&gt;
&lt;li&gt;If possible, test on a &lt;strong&gt;computer monitor&lt;/strong&gt; known for low input lag.&lt;/li&gt;
&lt;li&gt;Swap connection types: &lt;strong&gt;Ethernet&lt;/strong&gt; vs Wi‑Fi. If Ethernet fixes it, your display is probably fine.&lt;/li&gt;
&lt;li&gt;If both feel bad, you may be hitting server distance or service-side variance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Display lag numbers vary wildly. Many modern TVs can be around &lt;strong&gt;10–20 ms&lt;/strong&gt; in game mode, but can be much higher outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick troubleshooting table (symptom → measurement → fix)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom you feel&lt;/th&gt;
&lt;th&gt;What to measure&lt;/th&gt;
&lt;th&gt;What usually fixes it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Smooth most of the time, but huge lag spikes when someone uploads&lt;/td&gt;
&lt;td&gt;Loaded latency during upload&lt;/td&gt;
&lt;td&gt;Enable SQM (CAKE/&lt;code&gt;fq_codel&lt;/code&gt;), lower upload shaper to ~90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constant “mushy” input even when the network seems fine&lt;/td&gt;
&lt;td&gt;Display mode / input lag&lt;/td&gt;
&lt;td&gt;Game Mode, disable motion smoothing, try a monitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random stutter on Wi‑Fi, worse at night&lt;/td&gt;
&lt;td&gt;RSSI, channel congestion&lt;/td&gt;
&lt;td&gt;Move to 5/6 GHz, change channel, reduce channel width&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video looks blocky during action scenes&lt;/td&gt;
&lt;td&gt;Throughput headroom, Wi‑Fi retries&lt;/td&gt;
&lt;td&gt;Ethernet, stronger Wi‑Fi, reduce interference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine on Ethernet but bad on mesh&lt;/td&gt;
&lt;td&gt;Hop count / backhaul type&lt;/td&gt;
&lt;td&gt;Wired backhaul, move node, or stop using mesh for gaming&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  My stance: if you’re paying per hour, treat your network like a budget
&lt;/h2&gt;

&lt;p&gt;This is one of those things where the boring answer is actually the right one. If Xbox Cloud Gaming moves toward pay‑as‑you‑go, the “hidden cost” isn’t the hourly rate. It’s variance.&lt;/p&gt;

&lt;p&gt;So here’s my prediction. We’re going to see a split: casual players will accept cloud gaming as a good-enough Netflix-for-games experience, and everyone else will either hardwire their setup or bounce back to local hardware. Per-hour billing will make that decision faster because it forces you to notice the tax.&lt;/p&gt;

&lt;p&gt;If you try one thing after reading this, don’t buy gear. Measure your baseline and loaded latency, then turn on SQM. The first time you play through a household upload without a latency cliff, you’ll never unsee it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/xbox-cloud-gaming-latency?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=xbox-cloud-gaming-latency" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>latency</category>
      <category>cloudgaming</category>
      <category>xbox</category>
    </item>
    <item>
      <title>Polars 2.0 Upgrade Guide [2026]: Streaming Default + CI Bench</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Thu, 03 Sep 2026 12:43:03 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/polars-20-upgrade-guide-2026-streaming-default-ci-bench-44g</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/polars-20-upgrade-guide-2026-streaming-default-ci-bench-44g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/polars-2-0-upgrade-guide" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Polars 2.0 Upgrade Guide: Breaking Changes, Streaming Default, and a CI Regression Harness
&lt;/h1&gt;

&lt;p&gt;One &lt;code&gt;pip install -U polars&lt;/code&gt; later, your tests start failing. Not because your logic is wrong. Because your rows came back in a different order.&lt;/p&gt;

&lt;p&gt;If you already searched for &lt;strong&gt;“polars 2.0 upgrade guide”&lt;/strong&gt;, that’s probably why you’re here.&lt;/p&gt;

&lt;p&gt;You’ll leave this guide with two things working:&lt;/p&gt;

&lt;p&gt;1) your codebase running on Polars 2.0 (or the 2.0 RC) without “mystery diffs”, and 2) a repeatable regression harness that measures &lt;strong&gt;wall time&lt;/strong&gt;, &lt;strong&gt;CPU time&lt;/strong&gt;, and &lt;strong&gt;peak memory (RSS)&lt;/strong&gt; for your real queries.&lt;/p&gt;

&lt;p&gt;Polars 2.0 is a rare “good breaking change”. It makes the fast path the default. The price is that you now have to be explicit about correctness requirements you were accidentally getting for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Polars 2.0 (and why does the upgrade feel scary)?
&lt;/h2&gt;

&lt;p&gt;Polars 2.0 is a major version of the Polars DataFrame library that changes defaults and tightens type behavior, most notably by running &lt;strong&gt;LazyFrame&lt;/strong&gt; queries on the &lt;strong&gt;streaming engine by default&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The anxiety comes from one specific sentence in the official announcement. As &lt;a href="https://pola.rs/posts/announcing-polars-2/" rel="noopener noreferrer"&gt;Ritchie Vink&lt;/a&gt; (creator/maintainer of Polars) explains, in Polars 2.0 &lt;strong&gt;calling &lt;code&gt;collect()&lt;/code&gt; on a &lt;code&gt;LazyFrame&lt;/code&gt; now defaults to the streaming engine&lt;/strong&gt;, and that engine &lt;strong&gt;does not guarantee row order&lt;/strong&gt; for some operations unless you opt in.&lt;/p&gt;

&lt;p&gt;That’s the trade.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You get real wins in memory and throughput. The announcement claims the streaming engine can be “easily &lt;strong&gt;5x faster&lt;/strong&gt; in aggregate” for many lazy queries.&lt;/li&gt;
&lt;li&gt;You lose the comforting illusion that output order will “probably” match input order for &lt;strong&gt;&lt;code&gt;join&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;group_by&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;unpivot&lt;/code&gt;&lt;/strong&gt;, and friends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have order-dependent tests, dashboard diffs, or downstream code that quietly assumes “left table order survives a join”, you will feel this upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming engine as default: the Lazy API now streams unless you stop it
&lt;/h2&gt;

&lt;p&gt;The headline breaking change from the upgrade docs is literal: “&lt;strong&gt;The Lazy API defaults to the streaming engine&lt;/strong&gt;” (&lt;a href="https://docs.pola.rs/releases/upgrade/2/" rel="noopener noreferrer"&gt;Polars 2.0-rc upgrade guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Polars 1.x: &lt;code&gt;lf.collect()&lt;/code&gt; typically used the in-memory engine unless you opted into streaming.&lt;/li&gt;
&lt;li&gt;Polars 2.0: &lt;code&gt;lf.collect()&lt;/code&gt; with &lt;code&gt;engine="auto"&lt;/code&gt; resolves to streaming by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How do I keep old behavior (in-memory engine) for &lt;code&gt;collect()&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;You have three levers. Use the smallest hammer that fits.&lt;/p&gt;

&lt;p&gt;1) &lt;strong&gt;Per call&lt;/strong&gt; (most explicit):&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in-memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2) &lt;strong&gt;Per query chain&lt;/strong&gt; (useful when you have a couple of known order-sensitive pipelines):&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lf&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;how&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in-memory&lt;/span&gt;&lt;span class="sh"&gt;"&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;3) &lt;strong&gt;Process-wide engine affinity&lt;/strong&gt; (fine for a staged rollout or a notebook session):&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;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;
&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_engine_affinity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in-memory&lt;/span&gt;&lt;span class="sh"&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 last one is straight out of the announcement example from &lt;a href="https://pola.rs/posts/announcing-polars-2/" rel="noopener noreferrer"&gt;Ritchie Vink&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My opinion: don’t flip global affinity in production just to keep tests passing. That’s a safety blanket, not a fix. The whole point of Polars 2.0 is that streaming is the default for a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does streaming change row order, and how do I maintain order?
&lt;/h2&gt;

&lt;p&gt;Row order changes when the streaming engine parallelizes or re-chunks data in a way that’s valid for the &lt;em&gt;result&lt;/em&gt; but not stable relative to the input.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://pola.rs/posts/announcing-polars-2/" rel="noopener noreferrer"&gt;Ritchie Vink&lt;/a&gt; calls out, row order is &lt;strong&gt;not guaranteed&lt;/strong&gt; for certain operations like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;join&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;group_by&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unpivot&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why did my join/group_by output order change after upgrading?
&lt;/h3&gt;

&lt;p&gt;Because you were relying on an implementation detail.&lt;/p&gt;

&lt;p&gt;In Polars 1.x, a lot of pipelines &lt;em&gt;looked&lt;/em&gt; stable because the in-memory engine often preserved a left-to-right vibe. With streaming as the default, the engine is allowed to reorder during joins and aggregations to hit performance and memory goals.&lt;/p&gt;

&lt;p&gt;If you need stable, observable order, Polars 2.0 makes you say so.&lt;/p&gt;

&lt;h3&gt;
  
  
  Maintain order only where it matters
&lt;/h3&gt;

&lt;p&gt;For joins, opt in with &lt;code&gt;maintain_order&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lf&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;how&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maintain_order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&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 mirrors the official example in the 2.0 pre-release post.&lt;/p&gt;

&lt;p&gt;For aggregations, you have a few options depending on what you mean by “order”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you want deterministic output ordering for group keys, explicitly sort:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lf&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&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;ul&gt;
&lt;li&gt;If you want “preserve input order semantics”, stop using a &lt;code&gt;group_by&lt;/code&gt; output order as a proxy for that. Decide what the ordering rule actually is (timestamp, key priority, revenue descending, whatever) and encode it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical rule I use in code review: &lt;strong&gt;if a test asserts row order after a &lt;code&gt;join&lt;/code&gt;/&lt;code&gt;group_by&lt;/code&gt;, it must also assert an explicit ordering rule.&lt;/strong&gt; Otherwise you’re testing vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking changes checklist: what actually breaks in real codebases
&lt;/h2&gt;

&lt;p&gt;The upgrade docs are exhaustive. Your team doesn’t need exhaustive. Your team needs a short list that prevents the three dumbest failure modes.&lt;/p&gt;

&lt;p&gt;Here’s the path I’d take if I were upgrading a production pipeline.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin versions and test the RC first.&lt;/strong&gt; GitHub releases already list &lt;strong&gt;Python Polars 2.0.0-rc.1&lt;/strong&gt; and the last &lt;strong&gt;1.44.x&lt;/strong&gt; line (&lt;a href="https://github.com/pola-rs/polars/releases" rel="noopener noreferrer"&gt;Polars releases&lt;/a&gt;). Don’t “pip install -U polars” blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit order-dependent tests&lt;/strong&gt; (joins, group_bys, unpivot). Fix them with &lt;code&gt;maintain_order&lt;/code&gt; or explicit &lt;code&gt;.sort()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop assuming &lt;code&gt;pl.read_csv&lt;/code&gt; is purely eager&lt;/strong&gt; (it now dispatches to scan + collect).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace &lt;code&gt;LazyFrame.profile()&lt;/code&gt;&lt;/strong&gt; usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run your regression harness&lt;/strong&gt; on representative datasets. Block merges if you cross a threshold.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s the work. Everything else is cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSV reading changes and eager/lazy surprises (read_* now dispatches to scan_*().collect())
&lt;/h2&gt;

&lt;p&gt;IO is where upgrades love to hide landmines.&lt;/p&gt;

&lt;p&gt;Polars 2.0 changes eager reads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pl.read_csv&lt;/code&gt; is now dispatched to &lt;code&gt;pl.scan_csv(...).collect()&lt;/code&gt; (&lt;a href="https://docs.pola.rs/releases/upgrade/2/" rel="noopener noreferrer"&gt;upgrade guide&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pl.read_ipc&lt;/code&gt; is now dispatched to &lt;code&gt;pl.scan_ipc(...).collect()&lt;/code&gt; for all non-&lt;code&gt;use_pyarrow&lt;/code&gt; inputs (same doc).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What should I change if my code uses &lt;code&gt;pl.read_csv&lt;/code&gt; / &lt;code&gt;pl.read_ipc&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;If your code was relying on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;file-like object behavior&lt;/li&gt;
&lt;li&gt;“eager read happens now” side effects&lt;/li&gt;
&lt;li&gt;subtle schema inference timing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…make the intent explicit.&lt;/p&gt;

&lt;h4&gt;
  
  
  If you actually want eager semantics
&lt;/h4&gt;

&lt;p&gt;You can keep &lt;code&gt;read_csv&lt;/code&gt;, but mentally treat it as “implemented via lazy scan + collect now”. If you need to control streaming vs in-memory behavior, do it explicitly via the lazy path:&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;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in-memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  If you want the benefits of lazy planning
&lt;/h4&gt;

&lt;p&gt;Lean into scanning and do the obvious pushdowns before you collect:&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;lf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# add filters/projections before collect
&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lf&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&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;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&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;Also note a smaller CSV change from the docs: &lt;code&gt;infer_schema_files&lt;/code&gt; default is now &lt;strong&gt;10&lt;/strong&gt;. If your dataset has weird “rare” types that only show up after file 10, you can absolutely see schema shifts unless you pin schema or bump that number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stricter Polars: type coercion, concat/union, and removed casts
&lt;/h2&gt;

&lt;p&gt;Polars has always been strict. Polars 2.0 is stricter, and I’m fine with that.&lt;/p&gt;

&lt;p&gt;The official announcement frames it as “fail fast” rather than letting pipelines run for &lt;strong&gt;20 minutes&lt;/strong&gt; before raising (same pre-release post).&lt;/p&gt;

&lt;p&gt;From the upgrade guide, the big “stricter” buckets you’ll actually notice:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;is_in()&lt;/code&gt; strict coercion (lossy casts no longer happen)
&lt;/h3&gt;

&lt;p&gt;Polars 2.0 makes coercion casts for &lt;code&gt;is_in()&lt;/code&gt; &lt;strong&gt;strict instead of lossy&lt;/strong&gt;. If you were comparing values across types and relying on silent conversion, expect failures.&lt;/p&gt;

&lt;p&gt;Fix: normalize types up front. This is boring work. It’s also the correct work.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pl.concat()&lt;/code&gt; / &lt;code&gt;pl.union()&lt;/code&gt; strict behavior
&lt;/h3&gt;

&lt;p&gt;The upgrade guide calls out “Update the strict behavior of &lt;code&gt;pl.concat()&lt;/code&gt;/&lt;code&gt;pl.union()&lt;/code&gt;.” If you were concatenating frames with mismatched schemas and expecting a “best effort”, Polars will now force you to be explicit.&lt;/p&gt;

&lt;p&gt;Fix: align schemas (add missing columns, cast dtypes) before concat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Casts and operations no longer supported
&lt;/h3&gt;

&lt;p&gt;This is the “you were doing a thing that looked convenient but wasn’t safe” section.&lt;/p&gt;

&lt;p&gt;Examples called out in the upgrade guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;disabled casting from integers to categoricals (and back)&lt;/li&gt;
&lt;li&gt;removed casts from string to temporal types&lt;/li&gt;
&lt;li&gt;disallowed casting from non-nested types to &lt;code&gt;pl.List(..)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;disallowed boolean operators between booleans and integer types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, it’s painful. But it’s also removing the kind of footguns that create silent data corruption and then eat your weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Raising informative errors: why this helps upgrades (even when it annoys you)
&lt;/h2&gt;

&lt;p&gt;Polars 2.0 treats “Raising informative errors” like a feature, not an afterthought (it’s a full section in the announcement).&lt;/p&gt;

&lt;p&gt;In practice, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;errors earlier in planning rather than during execution&lt;/li&gt;
&lt;li&gt;clearer type mismatch messaging&lt;/li&gt;
&lt;li&gt;fewer “it returned something, but not what you thought” situations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m pro this change.&lt;/p&gt;

&lt;p&gt;I’ve built internal developer tooling (including the &lt;strong&gt;SOC 2 scaffolding CLI at Rise People&lt;/strong&gt; that was adopted org-wide). “Force the right thing early” beats “debug the wrong thing later” every time. The upgrade pain is front-loaded, but the operational cost drops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a repeatable performance regression benchmark harness (wall, CPU, memory)
&lt;/h2&gt;

&lt;p&gt;Here’s the part most upgrade guides conveniently skip. You don’t just want “it seems faster on my laptop.” You want a harness you can run before and after the upgrade, with numbers you can gate in CI.&lt;/p&gt;

&lt;p&gt;You want to measure three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wall time&lt;/strong&gt;: “how long did it take end-to-end?”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU time&lt;/strong&gt;: “did we burn more CPU to get that wall time?”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peak RSS&lt;/strong&gt;: “did memory spike and put us into OOM territory?”&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Harness design: compare versions, not vibes
&lt;/h3&gt;

&lt;p&gt;A decent harness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pins exact versions (ex: &lt;code&gt;polars==1.44.1&lt;/code&gt; vs &lt;code&gt;polars==2.0.0-rc.1&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;runs the same query set on the same dataset&lt;/li&gt;
&lt;li&gt;warms up once (JIT, caches, and OS page cache will lie to you)&lt;/li&gt;
&lt;li&gt;emits machine-readable output (JSON) and a human summary (Markdown)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s a minimal but real harness you can drop into a repo.&lt;/p&gt;

&lt;h4&gt;
  
  
  1) Repo layout
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bench/
  queries.py
  run.py
  datasets/
    README.md
  baselines/
    polars-1.44.1.json
    polars-2.0.0-rc.1.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  2) Define representative queries (Lazy on purpose)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bench/queries.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;q1_filter_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&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;country&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;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;q2_join_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;right_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;left&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;how&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&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;Stats anchor: you’re defining &lt;strong&gt;2&lt;/strong&gt; queries here. In real teams I like starting with &lt;strong&gt;5–12&lt;/strong&gt; queries, but two is enough to get the harness mechanics correct and prove you can trust the output.&lt;/p&gt;

&lt;h4&gt;
  
  
  3) Measure wall, CPU, and peak RSS
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bench/run.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psutil&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bench.queries&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;q1_filter_project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q2_join_agg&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;polars_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;wall_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;cpu_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;peak_rss_mb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_rss_mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memory_info&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;rss&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RunResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="c1"&gt;# Warm-up run to reduce one-time effects
&lt;/span&gt;    &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;start_wall&lt;/span&gt; &lt;span class="o"&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;start_cpu&lt;/span&gt; &lt;span class="o"&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;process_time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_rss_mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Touch output so lazy work can't be optimized away
&lt;/span&gt;    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;

    &lt;span class="n"&gt;end_wall&lt;/span&gt; &lt;span class="o"&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;end_cpu&lt;/span&gt; &lt;span class="o"&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;process_time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;_rss_mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RunResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;polars_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__version__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;wall_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;end_wall&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start_wall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cpu_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;end_cpu&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start_cpu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;peak_rss_mb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;data_users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BENCH_USERS_CSV&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;data/users.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data_orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BENCH_ORDERS_CSV&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;data/orders.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;q1_filter_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_users&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q1_filter_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;q2_join_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_users&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q2_join_agg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;payload&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;polars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__version__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;python_version&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;os&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cpu_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cpu_count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;asdict&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&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;time&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Concrete numbers this harness always emits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cpu_count&lt;/code&gt; (e.g., &lt;strong&gt;8&lt;/strong&gt;, &lt;strong&gt;12&lt;/strong&gt;, &lt;strong&gt;32&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timestamp&lt;/code&gt; (Unix seconds)&lt;/li&gt;
&lt;li&gt;per-query &lt;code&gt;wall_seconds&lt;/code&gt;, &lt;code&gt;cpu_seconds&lt;/code&gt;, &lt;code&gt;peak_rss_mb&lt;/code&gt; (floats)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That stat density is intentional. If you can’t put numbers on it, you can’t enforce it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a regression gate (fail CI on thresholds)
&lt;/h3&gt;

&lt;p&gt;Now the important part. Turn metrics into a decision.&lt;/p&gt;

&lt;p&gt;Here’s a simple “compare against baseline” script. It fails if wall time regresses by more than &lt;strong&gt;15%&lt;/strong&gt; or peak RSS by more than &lt;strong&gt;20%&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="c1"&gt;# bench/compare.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index_by_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;baseline_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;current_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;index_by_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseline_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;index_by_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;wall_budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
    &lt;span class="n"&gt;rss_budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;

    &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;wall_ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wall_seconds&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="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wall_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;rss_ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peak_rss_mb&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="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peak_rss_mb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mf"&gt;1e-9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wall_ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;wall_budget&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&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;FAIL &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: wall &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wall_ratio&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rss_ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rss_budget&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&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;FAIL &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: rss &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rss_ratio&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same philosophy I use for &lt;a href="https://dev.to/pillars/ai-in-production"&gt;AI in production&lt;/a&gt;: don’t rely on “someone will notice” after the upgrade. Build a gate.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I run the harness in CI and store baselines?
&lt;/h3&gt;

&lt;p&gt;A pragmatic pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;store baselines in-repo in &lt;code&gt;bench/baselines/&lt;/code&gt; for small teams&lt;/li&gt;
&lt;li&gt;store baselines in object storage for larger teams (so you can compare across branches and runners)&lt;/li&gt;
&lt;li&gt;always log machine metadata so you don’t compare laptop runs to CI runs and call it science&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re already on a monorepo and you care about reproducible Python envs, use &lt;code&gt;uv&lt;/code&gt; to pin and create two environments. My setup guidance is in &lt;a href="https://dev.to/glossary/ci-cd"&gt;CI/CD&lt;/a&gt; heavy workflows like my &lt;a href="https://dev.to/blog/python-uv-workspace-monorepo-2026"&gt;Python uv workspace monorepo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare query plans across versions (logical vs physical) to explain perf changes
&lt;/h2&gt;

&lt;p&gt;Polars gives you good tooling, but Polars 2.0 changes a default that matters when you’re diffing plans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;show_graph()&lt;/code&gt; default &lt;code&gt;plan_stage&lt;/code&gt; changes to &lt;strong&gt;"physical"&lt;/strong&gt; in 2.0 (&lt;a href="https://docs.pola.rs/releases/upgrade/2/" rel="noopener noreferrer"&gt;upgrade guide&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if you were used to staring at an optimized logical plan, you might now be staring at a physical plan and thinking “why does this look totally different?” That’s not Polars being weird. That’s you looking at a different stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  A plan-diff workflow that doesn’t lie
&lt;/h3&gt;

&lt;p&gt;I do this in two passes.&lt;/p&gt;

&lt;p&gt;1) Capture the &lt;em&gt;optimized logical&lt;/em&gt; plan (if you care about logical-level rewrites).&lt;br&gt;
2) Capture the &lt;em&gt;physical&lt;/em&gt; plan (because execution differences show up here, especially with streaming).&lt;/p&gt;

&lt;p&gt;Example:&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="c1"&gt;# capture_plans.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;polars&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;

&lt;span class="n"&gt;lf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/orders.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPT LOGICAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;optimized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PHYSICAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;optimized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Numbers to keep constant during plan comparisons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Polars version (ex: &lt;strong&gt;1.44.1&lt;/strong&gt; vs &lt;strong&gt;2.0.0-rc.1&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;dataset size (rows and file size)&lt;/li&gt;
&lt;li&gt;engine selection (&lt;code&gt;engine="auto"&lt;/code&gt; vs &lt;code&gt;engine="in-memory"&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If performance changes, you should be able to point at a physical operator that changed. “It got slower” is a complaint, not a diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  API reshapes, deprecations, and the &lt;code&gt;LazyFrame.profile()&lt;/code&gt; replacement
&lt;/h2&gt;

&lt;p&gt;Two things worth calling out because they show up in real repos.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;LazyFrame.profile()&lt;/code&gt; is removed
&lt;/h3&gt;

&lt;p&gt;Polars 2.0 removes &lt;code&gt;LazyFrame.profile()&lt;/code&gt; (&lt;a href="https://docs.pola.rs/releases/upgrade/2/" rel="noopener noreferrer"&gt;upgrade guide&lt;/a&gt;).&lt;/p&gt;

&lt;h4&gt;
  
  
  What replaces &lt;code&gt;LazyFrame.profile()&lt;/code&gt; in 2.0 and how do I profile queries now?
&lt;/h4&gt;

&lt;p&gt;There isn’t a 1:1 replacement that gives you the exact same shape.&lt;/p&gt;

&lt;p&gt;What I do instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;explain()&lt;/code&gt; and physical plan inspection to validate expected operators&lt;/li&gt;
&lt;li&gt;wrap your &lt;code&gt;.collect()&lt;/code&gt; in the harness above to get &lt;strong&gt;CPU&lt;/strong&gt;, &lt;strong&gt;wall&lt;/strong&gt;, &lt;strong&gt;peak RSS&lt;/strong&gt; at the query boundary&lt;/li&gt;
&lt;li&gt;for deep dives, use system profilers (Linux &lt;code&gt;perf&lt;/code&gt;, macOS Instruments) rather than library-level profiling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point annoys people because it’s more work. It’s also how you get answers you can actually trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removal of deprecated functionality / Deprecations
&lt;/h3&gt;

&lt;p&gt;The upgrade guide has a long list of removals and deprecations. Don’t read it like a novel. Grep your codebase and fix what you actually use.&lt;/p&gt;

&lt;p&gt;I’d prioritize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;anything in IO (because behavior changes can be subtle)&lt;/li&gt;
&lt;li&gt;anything in casting (because correctness changes can be quiet until they explode)&lt;/li&gt;
&lt;li&gt;anything in selectors/expressions (because it can break at runtime)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My upgrade stance (and a prediction)
&lt;/h2&gt;

&lt;p&gt;Polars 2.0 is the right kind of breaking change. It makes the performance engine the default and forces engineers to state correctness requirements instead of inheriting them from accidental behavior.&lt;/p&gt;

&lt;p&gt;But teams that treat data pipelines like “scripts” are going to have a bad month. This upgrade punishes the exact habits that feel fine when you’re hacking and become expensive when you’re operating.&lt;/p&gt;

&lt;p&gt;If you want to stay ahead of it, do this: ship the regression harness, pin a baseline on &lt;strong&gt;1.44.x&lt;/strong&gt;, and start testing against &lt;strong&gt;2.0.0-rc.1&lt;/strong&gt; now. When 2.0 GA lands, you won’t be debugging row order in a panic. You’ll be reading a JSON diff.&lt;/p&gt;

&lt;p&gt;And my prediction: within 6–12 months, “order-dependent joins” in Polars will be treated like relying on dict ordering in old Python. Everybody did it. Then everybody learned to stop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/polars-2-0-upgrade-guide?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=polars-2-0-upgrade-guide" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>polars</category>
      <category>dataengineering</category>
      <category>performance</category>
    </item>
    <item>
      <title>Execution Trace Tree for AI Agents: Build One in 60 Minutes</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:43:03 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/execution-trace-tree-for-ai-agents-build-one-in-60-minutes-2mbm</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/execution-trace-tree-for-ai-agents-build-one-in-60-minutes-2mbm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/execution-trace-tree-agents" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Execution Trace Tree for AI Agents: Build One in 60 Minutes
&lt;/h1&gt;

&lt;p&gt;Last week I watched someone try to debug an agent by tailing logs and “just increasing verbosity.” It produced 40,000 lines of JSON and exactly zero insight.&lt;/p&gt;

&lt;p&gt;You’re going to end this tutorial with an &lt;strong&gt;execution trace tree for AI agents&lt;/strong&gt; that you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;capture on every run (LLM calls, tool calls, retrieval, memory, guardrails)&lt;/li&gt;
&lt;li&gt;replay from checkpoints without guessing&lt;/li&gt;
&lt;li&gt;diff run A vs run B and point at the exact node where behavior diverged&lt;/li&gt;
&lt;li&gt;export into your existing tracing stack via OpenTelemetry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Give yourself ~60 minutes if you already have an agent loop running.&lt;/p&gt;

&lt;p&gt;2026 is when this stopped being optional. People are finally writing about tracing agent workflows with systems like AWS X‑Ray, and the “why does it fail only in prod?” stories all rhyme. Raw logs don’t show causality. A structured trace tree does.&lt;/p&gt;

&lt;p&gt;I’ll be blunt: &lt;strong&gt;if you’re building &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; for anything real, log-only debugging is malpractice&lt;/strong&gt;. You need a deterministic model of what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an execution trace tree for AI agents?
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;execution trace tree for AI agents&lt;/strong&gt; is a structured parent/child history of a single agent run. Every step that can change behavior (LLM call, tool call, retrieval, memory read/write, guardrail decision, human approval) becomes a node with enough metadata to reproduce the run and compare it to another run.&lt;/p&gt;

&lt;p&gt;It’s “distributed tracing,” but pointed inward at your agent loop instead of outward at your microservices.&lt;/p&gt;

&lt;p&gt;In a web request, fan-out is usually HTTP calls. In an agent run, fan-out is weirder: a planning step branches into tool calls, retrieval, retries, “let me ask again with a different prompt,” and sometimes a human gate.&lt;/p&gt;

&lt;p&gt;Two rules make trace trees actually useful (instead of “yet another telemetry thing”):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every node is a span.&lt;/strong&gt; Timing, parent, attributes. Same mental model as OTel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every node is replayable.&lt;/strong&gt; Persist the minimum inputs/outputs (or hashes + artifact references) so you can restart from checkpoints.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you already read my take on vendor-neutral observability, this is the debugging model that sits underneath the dashboards: &lt;a href="https://dev.to/blog/llm-observability-vendor-neutral"&gt;How to Build Vendor-Neutral LLM Observability Monitoring [2026]&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why logs aren’t enough for agent debugging (and when they actively mislead you)
&lt;/h2&gt;

&lt;p&gt;When agents get flaky in production, teams almost always do the same thing first. Crank log volume up by 10x. It feels responsible. It also turns your on-call into archaeology.&lt;/p&gt;

&lt;p&gt;Here’s what log-only systems consistently fail at in agentic systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Causality beats chronology
&lt;/h3&gt;

&lt;p&gt;Logs give you a timeline. Agents fail because of &lt;strong&gt;dependencies&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A classic failure mode: the agent generates a tool call with a slightly different argument shape. The tool returns a subtly different payload. That changes retrieval. That changes the final answer.&lt;/p&gt;

&lt;p&gt;In logs, this is 600 lines sprinkled across async workers and retries.&lt;/p&gt;

&lt;p&gt;In a trace tree, it’s one path you can walk:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;plan → tool.search → retrieval → answer&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That’s the difference between “I think it was the search tool?” and “this exact search result changed, and everything downstream followed.”&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Async fan-out kills “grepability”
&lt;/h3&gt;

&lt;p&gt;Real agent runs do parallel tool calls, retries, background evaluators, and queues. Once you have concurrency, grepping logs becomes a coping mechanism.&lt;/p&gt;

&lt;p&gt;If you’ve ever fought broken tracing context in a web stack, you know the smell: traces fragment, or worse, leak across requests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/ahmed_mahmoud360/observability-in-the-nextjs-app-router-field-notes-on-instrumentationts-onrequesterror-and-the-3ke3"&gt;Ahmed Mahmoud&lt;/a&gt; documented this kind of context leakage in Next.js instrumentation. Agents are even more sensitive because the “request” isn’t a single handler. It’s a long-running orchestration with lots of side quests.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Diffing runs is basically impossible
&lt;/h3&gt;

&lt;p&gt;Most agent regressions aren’t hard failures. They’re the annoying ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“It answered differently today.”&lt;/li&gt;
&lt;li&gt;“It used to call the tool. Now it doesn’t.”&lt;/li&gt;
&lt;li&gt;“It started citing the wrong doc.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To debug that, you need a comparison across runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model changed (&lt;code&gt;gpt-4.1&lt;/code&gt; → &lt;code&gt;gpt-4.1-mini&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;temperature changed (0.2 → 0.7)&lt;/li&gt;
&lt;li&gt;tool output changed (external API returned different data)&lt;/li&gt;
&lt;li&gt;retrieval docs changed (index update)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A trace tree makes those changes obvious and diffable. Logs don’t.&lt;/p&gt;

&lt;p&gt;If you care about &lt;a href="https://dev.to/glossary/production-ai"&gt;production AI&lt;/a&gt;, this is the line between demo debugging and actual engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spans, parent/child relationships, and how they become an execution tree
&lt;/h2&gt;

&lt;p&gt;Distributed tracing already solved the shape problem. A &lt;strong&gt;trace&lt;/strong&gt; is a collection of &lt;strong&gt;spans&lt;/strong&gt;. Spans have IDs and parent IDs. That gives you a tree.&lt;/p&gt;

&lt;p&gt;OpenTelemetry (OTel) standardized the model and the plumbing. You don’t need to adopt every piece of OTel on day one. You do need to stop inventing your own half-trace format that can’t join the rest of your telemetry.&lt;/p&gt;

&lt;p&gt;If you’re already instrumenting services, your agent trace should connect to the same trace graph as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API request that triggered the agent&lt;/li&gt;
&lt;li&gt;the database calls the agent made&lt;/li&gt;
&lt;li&gt;the queue workers it fanned out to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That only happens if you treat the agent run as a first-class trace, not “some logs in a side table.”&lt;/p&gt;

&lt;p&gt;A minimal agent run trace is usually &lt;strong&gt;20–200 spans per run&lt;/strong&gt;. That’s not a vibe. That’s a planning number. If your agent does 8 tool calls with 2 retries each and a planner loop of 6 steps, you’re already there.&lt;/p&gt;

&lt;p&gt;If you want the broader “agent observability stack” context, I wrote about wiring this into exporters here: &lt;a href="https://dev.to/blog/opentelemetry-ai-agents-instrumentation"&gt;OpenTelemetry Instrumentation for AI Agents [2026]: Ship It&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal trace-tree schema (node types + required fields)
&lt;/h2&gt;

&lt;p&gt;This is the part most posts dodge. They say “add tracing” and call it a day. Then you implement it, and six weeks later you realize you didn’t record the one field that would have explained the incident.&lt;/p&gt;

&lt;p&gt;My bias: a good schema is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;small enough you’ll ship it&lt;/li&gt;
&lt;li&gt;strict enough that diffing and replay aren’t a fantasy&lt;/li&gt;
&lt;li&gt;compatible with OpenTelemetry span attributes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Node (span) types you should support
&lt;/h3&gt;

&lt;p&gt;You don’t need 30 event types. You need the ones that change control flow or create nondeterminism.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;root.run&lt;/strong&gt; — one per agent run&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;router/planner&lt;/strong&gt; — “what should we do next?” decisions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llm.call&lt;/strong&gt; — model invocation (system prompt + messages)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tool.call&lt;/strong&gt; — anything with side effects (HTTP, DB, shell, email)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;retrieval&lt;/strong&gt; — anything RAG-like (query, topK, doc IDs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;memory.read / memory.write&lt;/strong&gt; — long-term memory and state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;guardrail&lt;/strong&gt; — allow/deny/redact decisions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;human.approval&lt;/strong&gt; — HITL gates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;checkpoint&lt;/strong&gt; — replay boundary&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This maps cleanly to how most &lt;a href="https://dev.to/glossary/agent-framework"&gt;agent framework&lt;/a&gt; runtimes behave.&lt;/p&gt;

&lt;p&gt;If your agent does &lt;a href="https://dev.to/glossary/rag"&gt;RAG&lt;/a&gt;, make retrieval a first-class span. Otherwise you’ll be stuck arguing about “why did it cite that doc?” with nothing but vibes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Required fields per span
&lt;/h3&gt;

&lt;p&gt;Here’s the compact table I use when I’m designing instrumentation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Span type&lt;/th&gt;
&lt;th&gt;Required fields (minimum)&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;root.run&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;run_id&lt;/code&gt;, &lt;code&gt;agent_version&lt;/code&gt;, &lt;code&gt;input_hash&lt;/code&gt;, &lt;code&gt;user_id_hash&lt;/code&gt;, &lt;code&gt;start_ts&lt;/code&gt;, &lt;code&gt;end_ts&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;identity, grouping, compliance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;router/planner&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;policy_version&lt;/code&gt;, &lt;code&gt;decision&lt;/code&gt;, &lt;code&gt;candidates&lt;/code&gt;, &lt;code&gt;reason_code&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;explains control flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llm.call&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;provider&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;max_tokens&lt;/code&gt;, &lt;code&gt;prompt_hash&lt;/code&gt;, &lt;code&gt;messages_hash&lt;/code&gt;, &lt;code&gt;tool_schema_hash&lt;/code&gt;, &lt;code&gt;response_hash&lt;/code&gt;, &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;output_tokens&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;replay + cost + drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tool.call&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tool_name&lt;/code&gt;, &lt;code&gt;tool_version&lt;/code&gt;, &lt;code&gt;args_hash&lt;/code&gt;, &lt;code&gt;result_hash&lt;/code&gt;, &lt;code&gt;side_effect=true/false&lt;/code&gt;, &lt;code&gt;status_code&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;determinism boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retrieval&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;index_version&lt;/code&gt;, &lt;code&gt;query_hash&lt;/code&gt;, &lt;code&gt;top_k&lt;/code&gt;, &lt;code&gt;doc_ids&lt;/code&gt;, &lt;code&gt;reranker_version&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;explains context changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory.*&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;store&lt;/code&gt;, &lt;code&gt;key_hash&lt;/code&gt;, &lt;code&gt;value_hash&lt;/code&gt;, &lt;code&gt;op=read/write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;state drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;guardrail&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rule_id&lt;/code&gt;, &lt;code&gt;decision&lt;/code&gt;, &lt;code&gt;redaction_count&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;safety + debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human.approval&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;approver_role&lt;/code&gt;, &lt;code&gt;decision&lt;/code&gt;, &lt;code&gt;wait_ms&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;latency + governance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checkpoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;checkpoint_id&lt;/code&gt;, &lt;code&gt;state_hash&lt;/code&gt;, &lt;code&gt;artifact_refs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;restart points&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two concrete limits I’d enforce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cap any &lt;em&gt;single&lt;/em&gt; span attribute blob to &lt;strong&gt;16 KB&lt;/strong&gt; (anything larger belongs in an artifact store)&lt;/li&gt;
&lt;li&gt;cap the total persisted trace payload per run to &lt;strong&gt;256 KB&lt;/strong&gt; before sampling kicks in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you ignore this, you’ll accidentally turn “observability” into your newest cost center. If you’re thinking about &lt;a href="https://dev.to/glossary/llm-cost"&gt;LLM cost&lt;/a&gt;, trace payload size is part of that equation.&lt;/p&gt;

&lt;p&gt;For more on metrics you can attach to these spans (latency, cost, quality), see: &lt;a href="https://dev.to/blog/llm-observability-metrics"&gt;How to Pick LLM Application Observability Metrics [2026]&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checkpoints, deterministic replay, and trace diffing (the workflow that actually saves you)
&lt;/h2&gt;

&lt;p&gt;Most teams stop at “we can see the spans.” That’s table stakes. The payoff is &lt;strong&gt;replay + diff&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Define what “deterministic” means for your agent
&lt;/h3&gt;

&lt;p&gt;Your agent is deterministic if, given the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prompt/messages&lt;/li&gt;
&lt;li&gt;tool schema&lt;/li&gt;
&lt;li&gt;retrieval corpus + index version&lt;/li&gt;
&lt;li&gt;tool outputs (or captured tool responses)&lt;/li&gt;
&lt;li&gt;model + sampling params&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…it produces the same downstream actions.&lt;/p&gt;

&lt;p&gt;That’s why I treat &lt;code&gt;tool.call&lt;/code&gt; as the determinism boundary. External APIs change. Time changes. Randomness changes. Your “perfect prompt” is not the thing that makes the run reproducible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Add checkpoints at control-flow boundaries
&lt;/h3&gt;

&lt;p&gt;Put a &lt;strong&gt;checkpoint span&lt;/strong&gt; after events that are expensive to recompute or likely to fork behavior.&lt;/p&gt;

&lt;p&gt;My default list (5 checkpoints):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;after initial user input normalization&lt;/li&gt;
&lt;li&gt;after planning/router decision&lt;/li&gt;
&lt;li&gt;after retrieval context is assembled&lt;/li&gt;
&lt;li&gt;before any irreversible tool side effect (&lt;code&gt;side_effect=true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;after final answer is generated&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s enough to replay most bugs without re-running the entire world.&lt;/p&gt;

&lt;p&gt;If you’re already thinking in control-flow terms, this pairs nicely with: &lt;a href="https://dev.to/blog/ai-agent-control-flow-patterns"&gt;AI Agent Control Flow Patterns [2026]: Retries, HITL, Checkpoints&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Capture “tool snapshots” for replay
&lt;/h3&gt;

&lt;p&gt;To replay safely, you have two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;record-and-replay&lt;/strong&gt;: store the tool output (or a reference to it) and return it during replay&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sandbox replay&lt;/strong&gt;: re-run tools in a sandbox with fixed fixtures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, you’ll do both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;record-and-replay for third-party APIs&lt;/li&gt;
&lt;li&gt;sandbox replay for your own services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where workflow engines earned their reputation. Temporal’s whole pitch is replaying workflow code against an event history. Even if you’re not using Temporal, the determinism concept transfers cleanly.&lt;/p&gt;

&lt;p&gt;If you want the full workflow-engine framing: &lt;a href="https://dev.to/blog/temporal-workflow-engine-guide"&gt;Temporal Workflow Engine: The Reliability Layer Your Distributed System Is Missing [2026 Guide]&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Diff two runs node-by-node
&lt;/h3&gt;

&lt;p&gt;Diffing is the killer feature. The only question that matters in a regression is: &lt;strong&gt;what changed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A practical diff algorithm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;align root spans by &lt;code&gt;agent_version&lt;/code&gt; and &lt;code&gt;input_hash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;walk the tree in execution order&lt;/li&gt;
&lt;li&gt;compare each node’s &lt;code&gt;*_hash&lt;/code&gt; attributes&lt;/li&gt;
&lt;li&gt;stop at the first divergence and expand the subtree&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you’ll usually find:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;retrieval.doc_ids&lt;/code&gt; differs because the index version changed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tool.result_hash&lt;/code&gt; differs because an API returned new data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.call.model&lt;/code&gt; differs because routing changed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.call.temperature&lt;/code&gt; differs because someone “tuned creativity”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the moment debugging becomes boring. Boring is good.&lt;/p&gt;

&lt;p&gt;This plugs straight into evals too. I built this site’s publishing pipeline as a multi-step, idempotent system with deterministic gates. The lesson transferred cleanly: deterministic structure beats “more model.” Based on the pipeline I run for this site (261+ posts published and weekly automated feedback loops), &lt;strong&gt;deterministic gates catch failures earlier than just upgrading the review model&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You can tie trace diffing into your eval workflow here: &lt;a href="https://dev.to/blog/agent-evaluation-harness-replay"&gt;Agent Evaluation Harness [2026]: Replay, Rubrics, CI Gates&lt;/a&gt; and &lt;a href="https://dev.to/blog/ai-engineering-evals-gates"&gt;AI Engineering Evals: Regression Gates for Prompts, Tools, RAG [2026]&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenTelemetry context propagation, redaction, sampling, and cost control
&lt;/h2&gt;

&lt;p&gt;This is where most “agent tracing” tutorials die in production. The demo works. The first incident hits. Context fragments, or you accidentally store secrets, or your tracing bill starts looking like a second LLM bill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context propagation: don’t let your trace fragment (or leak)
&lt;/h3&gt;

&lt;p&gt;If you have async tool calls, queues, or background workers, you need to propagate context.&lt;/p&gt;

&lt;p&gt;Hard requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a stable &lt;code&gt;trace_id&lt;/code&gt; for the whole run&lt;/li&gt;
&lt;li&gt;a new &lt;code&gt;span_id&lt;/code&gt; per node&lt;/li&gt;
&lt;li&gt;propagation across process boundaries (HTTP, queues)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When context propagation breaks, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;orphan spans&lt;/li&gt;
&lt;li&gt;partial traces&lt;/li&gt;
&lt;li&gt;cross-run contamination (the scariest one)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s why Ahmed Mahmoud’s Next.js story matters here. The exact same bug class shows up in agent orchestrators when spans are stored in globals or threadlocals incorrectly.&lt;/p&gt;

&lt;p&gt;If you’re building agent infrastructure, you want an orchestration layer that makes context explicit, not implicit. See: &lt;a href="https://dev.to/blog/ai-agent-observability-logging-schema"&gt;AI Agent Observability Logging Schema [2026]: OTel + Redaction&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redaction and security: store less than you think
&lt;/h3&gt;

&lt;p&gt;Agent traces contain the worst kind of data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;user input (often PII)&lt;/li&gt;
&lt;li&gt;prompts (often secrets and system rules)&lt;/li&gt;
&lt;li&gt;tool outputs (often internal data)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A sane baseline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;store raw prompts only in dev, never in prod&lt;/li&gt;
&lt;li&gt;store hashes in prod by default (&lt;code&gt;prompt_hash&lt;/code&gt;, &lt;code&gt;messages_hash&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;store structured “safe excerpts” only after redaction&lt;/li&gt;
&lt;li&gt;keep artifact blobs in a separate store with retention controls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also where &lt;a href="https://dev.to/glossary/prompt-injection"&gt;prompt injection&lt;/a&gt; becomes an observability problem. If your trace store is searchable and you persist raw tool outputs, you created a new exfiltration surface.&lt;/p&gt;

&lt;p&gt;If you’re building RAG-heavy systems, the redaction patterns carry over directly from: &lt;a href="https://dev.to/blog/field-level-redaction-rag"&gt;How to Implement Field-Level Redaction for RAG Pipelines [2026]&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if your team needs the broader posture: &lt;a href="https://dev.to/blog/ai-security-leader-playbook"&gt;AI Security Leader Playbook [2026]: 10 Controls That Ship&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sampling and cost control: trace trees can get expensive fast
&lt;/h3&gt;

&lt;p&gt;My default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep &lt;strong&gt;100%&lt;/strong&gt; of traces for failures&lt;/li&gt;
&lt;li&gt;keep &lt;strong&gt;10%&lt;/strong&gt; of traces for successes&lt;/li&gt;
&lt;li&gt;keep &lt;strong&gt;1%&lt;/strong&gt; of traces for high-volume endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Attach token/cost metadata at the &lt;code&gt;llm.call&lt;/code&gt; spans so you can see which node blew the budget.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on cost math, start here: &lt;a href="https://dev.to/blog/agent-per-task-cost-calculation"&gt;Agent Per-Task Cost Calculation [2026]: Retries, Tools, Caching&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Data anchor (site-owned): the GSC keyword winnability dataset I run for kunalganglani.com shows this topic neighborhood has &lt;strong&gt;~1,083 related impressions&lt;/strong&gt; already and a demand estimate around &lt;strong&gt;~11,000 searches/month&lt;/strong&gt; across related agent observability queries. That’s the clearest signal I have that “agent debugging models” are actively being searched for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using trace trees for evaluation (regressions, scorecards, and alerts)
&lt;/h2&gt;

&lt;p&gt;Once every run is a tree, evaluation stops being hand-wavy.&lt;/p&gt;

&lt;p&gt;Three concrete ways I’d wire it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Label traces&lt;/strong&gt;: &lt;code&gt;pass/fail&lt;/code&gt;, &lt;code&gt;task_type&lt;/code&gt;, &lt;code&gt;customer_tier&lt;/code&gt;, &lt;code&gt;release_sha&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score nodes&lt;/strong&gt;: attach a rubric score to the exact &lt;code&gt;llm.call&lt;/code&gt; or &lt;code&gt;retrieval&lt;/code&gt; span that caused failure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on drift&lt;/strong&gt;: when &lt;code&gt;retrieval.doc_ids&lt;/code&gt; churn spikes after an index deploy, page someone&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A good eval program doesn’t need a PhD. It needs a weekly habit and a stable artifact format. I wrote a lightweight approach here: &lt;a href="https://dev.to/blog/agent-evaluation-roadmap-teams"&gt;Agent Evaluation Roadmap for Small Teams [2026]: The 30-Min/Week Plan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you’re running any kind of &lt;a href="https://dev.to/glossary/ai-in-production"&gt;AI in production&lt;/a&gt;, you eventually run into the quiet truth: &lt;strong&gt;evaluation is observability with opinions&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where I think this goes next
&lt;/h3&gt;

&lt;p&gt;By the end of 2026, “agent frameworks” that don’t natively emit an execution trace tree will feel like web frameworks that don’t support request IDs. You can technically ship without it. Serious teams won’t.&lt;/p&gt;

&lt;p&gt;If you’re building agents now, here’s my challenge: implement the minimal schema above and force yourself to debug the next incident by diffing two trace trees, not by grepping logs. Once you feel how fast that is, you’ll start getting annoyed at any system that can’t do it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/execution-trace-tree-agents?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=execution-trace-tree-agents" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>debugging</category>
      <category>observability</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>How to Build Vendor-Neutral LLM Observability Monitoring [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Wed, 02 Sep 2026 12:45:17 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-build-vendor-neutral-llm-observability-monitoring-2026-56n3</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-build-vendor-neutral-llm-observability-monitoring-2026-56n3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/llm-observability-vendor-neutral" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  How to Build Vendor-Neutral LLM Observability Monitoring [2026]
&lt;/h1&gt;

&lt;p&gt;If you want &lt;strong&gt;llm observability monitoring vendor neutral&lt;/strong&gt;, you need to stop treating your tracing vendor like the source of truth.&lt;/p&gt;

&lt;p&gt;In 60–90 minutes, you can get a defensible setup: OpenTelemetry end-to-end traces, hard redaction boundaries, tail-based sampling so costs don’t go feral, and &lt;strong&gt;dual export&lt;/strong&gt; so you can use a vendor UI without handing them your raw prompts.&lt;/p&gt;

&lt;p&gt;The production gap I keep seeing is painfully consistent. Teams ship a GenAI prototype, bolt on whatever tracing SDK the vendor recommends, and then three weeks later they realize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Their “trace” is basically a vendor-shaped JSON blob.&lt;/li&gt;
&lt;li&gt;Prompts and responses leaked into places nobody can delete.&lt;/li&gt;
&lt;li&gt;Observability spend quietly becomes a real line item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running this blog’s multi-agent publishing pipeline taught me a boring truth: &lt;strong&gt;deterministic gates beat hero debugging&lt;/strong&gt;. Same deal here. Telemetry is a contract. Enforce it at choke points. Make sampling and redaction defaults, not backlog tickets.&lt;/p&gt;

&lt;p&gt;I’m not going to re-teach OpenTelemetry instrumentation. If you need the app-side wiring, start with my &lt;a href="https://dev.to/blog/opentelemetry-ai-agents-instrumentation"&gt;OpenTelemetry instrumentation&lt;/a&gt; post. This one is about what actually breaks in production: schema discipline, redaction, sampling, routing, and retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Vendor-Neutral LLM Observability Monitoring?
&lt;/h2&gt;

&lt;p&gt;Vendor-neutral LLM observability monitoring is &lt;strong&gt;collecting and storing LLM traces, logs, and metrics in an open, portable format (typically OTLP via OpenTelemetry) so you can switch vendors without rewriting your app or losing historical comparability&lt;/strong&gt;, while still enforcing privacy and cost controls.&lt;/p&gt;

&lt;p&gt;My stance is simple: &lt;strong&gt;OTLP is the source of truth.&lt;/strong&gt; Vendor UIs are optional.&lt;/p&gt;

&lt;p&gt;Pay for a great UI if it helps you ship. Just don’t let the UI become your data model.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is observability (and why LLM apps make it worse)?
&lt;/h3&gt;

&lt;p&gt;Observability is the ability to answer new questions from production behavior. Not the ability to stare at dashboards until you feel better.&lt;/p&gt;

&lt;p&gt;The “monitoring vs observability” argument is mostly bike-shedding until you ship agentic systems. Then you discover LLM apps are high-cardinality by default: model IDs, tool names, retrieval queries, user tenants, prompt templates, safety filters, token counts. Everything varies per request.&lt;/p&gt;

&lt;p&gt;If you don’t design for that, you get one of two failures:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;All data, no insight&lt;/strong&gt;: you log everything, spend a fortune, and still can’t find the bad run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No data when it matters&lt;/strong&gt;: you sample blindly and miss the one trace you needed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tyler Edwards (Co-founder &amp;amp; CEO, Overmind) frames this as “data without insight” and the need to close the loop from telemetry to action in his piece: &lt;a href="https://dev.to/tyler007/so-you-have-observability-now-what-fj7"&gt;Tyler Edwards&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AI Gateway is (and why it’s your observability choke point)
&lt;/h2&gt;

&lt;p&gt;An AI gateway is a service layer between your app (or orchestrator) and one or more model providers. It centralizes the boring-but-critical stuff: auth, routing, rate limiting, caching, and observability.&lt;/p&gt;

&lt;p&gt;If you’re serious about production AI, you want &lt;strong&gt;one place&lt;/strong&gt; where every LLM call can be tagged, budgeted, and traced.&lt;/p&gt;

&lt;p&gt;Yuiko Koyanagi’s overview is useful for taxonomy and tradeoffs. See &lt;a href="https://dev.to/toffy/best-ai-gateways-with-built-in-observability-governance-2026-27dg"&gt;Yuiko Koyanagi&lt;/a&gt; for the “two families” framing (self-hosted vs managed). Where that competitor piece stops is exactly where real systems start hurting. It doesn’t give you an OTel + open-schema recipe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost structure (gateway-first thinking)
&lt;/h3&gt;

&lt;p&gt;Gateway cost is never just “gateway cost.” You’re paying for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM tokens&lt;/strong&gt; (input + output)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway compute&lt;/strong&gt; (parsing/streaming, retries, caching)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability data&lt;/strong&gt; (spans/logs/metrics volume, cardinality)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At volume, the observability portion becomes non-trivial. I’ve watched collectors emit more bytes than the model response because someone thought logging streaming chunks was “helpful.” It’s not helpful. It’s how you buy a telemetry firehose.&lt;/p&gt;

&lt;p&gt;A simple example that repeats everywhere: if you log the full prompt + full response for every request, your telemetry volume scales with output length. A 2,000-token response is normal in agent workflows. Congratulations, tracing is now your most expensive backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to choose a gateway (based on observability, not marketing)
&lt;/h3&gt;

&lt;p&gt;If I’m choosing a gateway, I don’t start with the vendor’s homepage. I start with five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can it emit &lt;strong&gt;OTLP&lt;/strong&gt; (or can I wrap it so it can)?&lt;/li&gt;
&lt;li&gt;Can it attach &lt;strong&gt;tenant + budget&lt;/strong&gt; metadata per request?&lt;/li&gt;
&lt;li&gt;Does it support &lt;strong&gt;streaming&lt;/strong&gt; without wrecking the trace model?&lt;/li&gt;
&lt;li&gt;Can it compute or forward &lt;strong&gt;token counts&lt;/strong&gt; reliably?&lt;/li&gt;
&lt;li&gt;Can I enforce &lt;strong&gt;redaction&lt;/strong&gt; before data leaves my trust boundary?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of these are “no,” you’re not buying a gateway. You’re buying a future migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal reference architecture: SDK → OTel Collector → dual export
&lt;/h2&gt;

&lt;p&gt;Here’s the smallest architecture I consider defensible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;App / orchestrator&lt;/strong&gt; emits spans and metrics using an OpenTelemetry SDK.&lt;/li&gt;
&lt;li&gt;(Optional but recommended) &lt;strong&gt;AI gateway&lt;/strong&gt; injects correlation IDs and budget attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry Collector&lt;/strong&gt; receives OTLP.&lt;/li&gt;
&lt;li&gt;Collector processors enforce:

&lt;ul&gt;
&lt;li&gt;redaction boundaries&lt;/li&gt;
&lt;li&gt;attribute normalization (open schema)&lt;/li&gt;
&lt;li&gt;tail-based sampling&lt;/li&gt;
&lt;li&gt;routing/dual export&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Export to:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vendor backend&lt;/strong&gt; (for UI, alerting, “nice graphs”)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your durable store&lt;/strong&gt; (object storage, ClickHouse, whatever you trust) for raw OTLP retention&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This “dual export” pattern is how you get portability without living in Grafana screenshots.&lt;/p&gt;

&lt;p&gt;One internal data point: this site already has query-neighborhood traction. Based on my GSC winnability output, the best related average position is &lt;strong&gt;~3.3 with 61 related impressions&lt;/strong&gt; for this topic neighborhood. That’s why I’m leaning into a concrete implementation guide instead of another conceptual think-piece.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipeline-level traces (end-to-end visibility)
&lt;/h3&gt;

&lt;p&gt;If you only trace “the LLM call,” you’re going to miss the failure mode that dominates real incidents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retrieval returned garbage&lt;/li&gt;
&lt;li&gt;tool call timed out&lt;/li&gt;
&lt;li&gt;model retried 3 times&lt;/li&gt;
&lt;li&gt;streaming stalled&lt;/li&gt;
&lt;li&gt;output got blocked by a safety filter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So your trace has to span:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request ingress&lt;/li&gt;
&lt;li&gt;retrieval / vector DB&lt;/li&gt;
&lt;li&gt;tool calls&lt;/li&gt;
&lt;li&gt;LLM call(s)&lt;/li&gt;
&lt;li&gt;post-processing (parsers, validators)&lt;/li&gt;
&lt;li&gt;response egress&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Correlation matters. If your vector DB is slow, you want to see it in the same trace. Not in a different dashboard owned by a different team.&lt;/p&gt;

&lt;p&gt;Here’s the mental model I use: &lt;strong&gt;treat an “agent run” like a distributed transaction&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A vendor-neutral LLM trace schema (the data contract)
&lt;/h2&gt;

&lt;p&gt;Most teams don’t have an observability problem. They have a &lt;strong&gt;data contract problem&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you want vendor neutrality, your schema needs to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;portable&lt;/strong&gt; (OTLP attributes, not vendor-specific JSON)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bounded&lt;/strong&gt; (no unbounded-cardinality attributes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;actionable&lt;/strong&gt; (maps cleanly to SLOs and incident workflows)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Below is a minimal schema that works for chat, RAG, and tool-using agents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Span taxonomy (keep it boring)
&lt;/h3&gt;

&lt;p&gt;Use a small set of span names and enforce them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm.request&lt;/code&gt; (root span for the user request)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.generate&lt;/code&gt; (each model invocation)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieval.query&lt;/code&gt; (vector DB / search)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tool.call&lt;/code&gt; (each tool invocation)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;guardrail.check&lt;/code&gt; (safety / policy checks)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache.lookup&lt;/code&gt; and &lt;code&gt;cache.write&lt;/code&gt; (if you have semantic caching)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already do service-level spans, these sit underneath your normal HTTP/gRPC spans.&lt;/p&gt;

&lt;h3&gt;
  
  
  Required attributes (portable and low-cardinality)
&lt;/h3&gt;

&lt;p&gt;At minimum, I want these on &lt;code&gt;llm.generate&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm.system&lt;/code&gt; = provider family (e.g. &lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;local&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.model&lt;/code&gt; = exact model ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.operation&lt;/code&gt; = &lt;code&gt;chat.completions&lt;/code&gt;, &lt;code&gt;responses&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.request_id&lt;/code&gt; = provider request ID (if available)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.input_tokens&lt;/code&gt; = integer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.output_tokens&lt;/code&gt; = integer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.total_tokens&lt;/code&gt; = integer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.cost_usd&lt;/code&gt; = decimal (if you can compute it)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.streamed&lt;/code&gt; = boolean&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On &lt;code&gt;retrieval.query&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;retrieval.backend&lt;/code&gt; = &lt;code&gt;pgvector&lt;/code&gt;, &lt;code&gt;qdrant&lt;/code&gt;, &lt;code&gt;pinecone&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieval.top_k&lt;/code&gt; = integer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieval.query_hash&lt;/code&gt; = stable hash (not the raw query)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieval.result_count&lt;/code&gt; = integer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On &lt;code&gt;tool.call&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tool.name&lt;/code&gt; = low-cardinality tool identifier&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tool.success&lt;/code&gt; = boolean&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tool.latency_ms&lt;/code&gt; = integer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on the root &lt;code&gt;llm.request&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tenant.id&lt;/code&gt; = stable tenant identifier&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user.id_hash&lt;/code&gt; = hashed user identifier&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prompt.template_id&lt;/code&gt; = stable template/version ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;release.version&lt;/code&gt; = git SHA or build ID&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Table: signal → OTel shape → suggested attribute
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal you need&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;th&gt;OTel shape&lt;/th&gt;
&lt;th&gt;Attribute / field&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;End-to-end latency&lt;/td&gt;
&lt;td&gt;entire request&lt;/td&gt;
&lt;td&gt;span&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;duration&lt;/code&gt; (span)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model latency&lt;/td&gt;
&lt;td&gt;per generation call&lt;/td&gt;
&lt;td&gt;span&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;duration&lt;/code&gt; + &lt;code&gt;llm.model&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token usage&lt;/td&gt;
&lt;td&gt;per generation call&lt;/td&gt;
&lt;td&gt;span attributes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llm.input_tokens&lt;/code&gt;, &lt;code&gt;llm.output_tokens&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$ cost&lt;/td&gt;
&lt;td&gt;per generation call&lt;/td&gt;
&lt;td&gt;span attribute / metric&lt;/td&gt;
&lt;td&gt;&lt;code&gt;llm.cost_usd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error rate&lt;/td&gt;
&lt;td&gt;request + tool calls&lt;/td&gt;
&lt;td&gt;span status&lt;/td&gt;
&lt;td&gt;span &lt;code&gt;status.code&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval quality proxy&lt;/td&gt;
&lt;td&gt;retrieval span&lt;/td&gt;
&lt;td&gt;span attributes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;retrieval.top_k&lt;/code&gt;, &lt;code&gt;retrieval.result_count&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety blocks&lt;/td&gt;
&lt;td&gt;guardrail span&lt;/td&gt;
&lt;td&gt;span events/attrs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;guardrail.result&lt;/code&gt; (e.g. &lt;code&gt;blocked&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt identity (not content)&lt;/td&gt;
&lt;td&gt;request span&lt;/td&gt;
&lt;td&gt;span attributes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prompt.template_id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table is the open-schema core. If a vendor wants extra fields, cool. Your contract stays stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redaction boundaries: where to redact, and how to prove it works
&lt;/h2&gt;

&lt;p&gt;Prompt logging is where teams accidentally turn into a data breach story.&lt;/p&gt;

&lt;p&gt;You need an explicit redaction boundary. There are three common places to do it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client-side redaction&lt;/strong&gt; (in app code): safest for PII, but consistency is a grind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway redaction&lt;/strong&gt;: great choke point, but only covers traffic routed through the gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collector redaction&lt;/strong&gt;: centralized enforcement, but it happens after the app already emitted telemetry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My take: do &lt;strong&gt;two-tier redaction&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tier 1 (app): never emit raw secrets/PII in the first place.&lt;/li&gt;
&lt;li&gt;Tier 2 (collector): strip anything that slips through before you export.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OpenTelemetry Collector gives you practical tools here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Attributes Processor&lt;/strong&gt; can insert/update/delete attributes on telemetry. See &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/attributesprocessor" rel="noopener noreferrer"&gt;OpenTelemetry contributors&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Transform Processor&lt;/strong&gt; supports OTTL transformations to rewrite/drop fields. See &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor" rel="noopener noreferrer"&gt;OpenTelemetry contributors&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What to store instead of raw prompts
&lt;/h3&gt;

&lt;p&gt;If you want debugging value without content leakage, store identity and shape, not the payload:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;prompt.template_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prompt.hash&lt;/code&gt; (hash of normalized prompt)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prompt.length_chars&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a prompt “class” (e.g. &lt;code&gt;support&lt;/code&gt;, &lt;code&gt;sales&lt;/code&gt;, &lt;code&gt;codegen&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;redacted excerpt&lt;/strong&gt; only for sampled traces&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How to prove redaction works (not just “trust me”)
&lt;/h3&gt;

&lt;p&gt;Treat this like a test. Not a policy doc nobody reads.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send a canary request containing a fake SSN like &lt;code&gt;000-00-0000&lt;/code&gt; and a fake API key pattern.&lt;/li&gt;
&lt;li&gt;Assert it &lt;strong&gt;never appears&lt;/strong&gt; in:

&lt;ul&gt;
&lt;li&gt;vendor backend&lt;/li&gt;
&lt;li&gt;your logs store&lt;/li&gt;
&lt;li&gt;your object storage raw export&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Ship it as CI if your gateway or collector config is code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you already do redaction for retrieval contexts, this complements it. I go deeper on field-level handling in &lt;a href="https://dev.to/glossary/retrieval-augmented-generation"&gt;retrieval-augmented generation&lt;/a&gt; systems in &lt;a href="https://dev.to/blog/field-level-redaction-rag"&gt;field-level redaction&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sampling strategy: head vs tail sampling for LLM traces
&lt;/h2&gt;

&lt;p&gt;Head sampling decides at the start of a trace. Tail sampling decides after you’ve seen the whole trace.&lt;/p&gt;

&lt;p&gt;For LLM systems, head sampling is the default mistake.&lt;/p&gt;

&lt;p&gt;You either drop “boring” traces and then discover the boring traces are the only ones that reproduce the bug. Or you keep too many and drown.&lt;/p&gt;

&lt;p&gt;Tail sampling is the right primitive for LLM observability because your sampling decision can use facts like cost, latency, and error status.&lt;/p&gt;

&lt;p&gt;The OpenTelemetry Collector’s tail sampling processor exists for this exact reason. See &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor" rel="noopener noreferrer"&gt;OpenTelemetry contributors&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical tail-sampling policy for LLM workloads
&lt;/h3&gt;

&lt;p&gt;I’d start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep &lt;strong&gt;100%&lt;/strong&gt; of traces where:

&lt;ul&gt;
&lt;li&gt;any span has error status&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.cost_usd&lt;/code&gt; &amp;gt;= &lt;code&gt;0.10&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;end-to-end latency &amp;gt;= &lt;code&gt;3,000ms&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Keep &lt;strong&gt;10%&lt;/strong&gt; of traces for each tenant as a baseline&lt;/li&gt;
&lt;li&gt;Keep &lt;strong&gt;1%&lt;/strong&gt; globally as a background sample&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those numbers are opinionated on purpose. If you don’t pick thresholds, you’ll “tune later,” and later never comes.&lt;/p&gt;

&lt;p&gt;Tail sampling also makes retention less painful. You can afford to store richer payload only for traces you kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost controls beyond sampling (observability spend is real)
&lt;/h2&gt;

&lt;p&gt;Sampling is necessary. It’s not sufficient.&lt;/p&gt;

&lt;p&gt;The second cost failure mode is &lt;strong&gt;cardinality explosions&lt;/strong&gt;. If you attach raw user IDs, raw queries, or raw prompt strings as attributes, your backend cost goes nonlinear. People still do this. Then they act surprised when their bill looks like a ransom note.&lt;/p&gt;

&lt;p&gt;Here are the controls I actually use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cardinality limits&lt;/strong&gt;: hash high-cardinality fields (&lt;code&gt;user.id_hash&lt;/code&gt;, &lt;code&gt;retrieval.query_hash&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention tiers&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;7 days: sampled traces with redacted excerpts&lt;/li&gt;
&lt;li&gt;30 days: metadata-only traces&lt;/li&gt;
&lt;li&gt;90+ days: aggregated metrics only&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget attributes&lt;/strong&gt;: attach &lt;code&gt;tenant.id&lt;/code&gt; and enforce per-tenant budgets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual pipelines&lt;/strong&gt;: keep OTLP raw in cheap storage, keep the vendor backend thin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming discipline&lt;/strong&gt;: don’t emit per-token logs. Emit summary counters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you’re doing serious attribution, connect this to your broader &lt;a href="https://dev.to/glossary/llm-cost"&gt;LLM cost&lt;/a&gt; work. I’ve written about per-run accounting in &lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;AI in production&lt;/a&gt; systems and how retries and tools change the math in &lt;a href="https://dev.to/blog/agent-per-task-cost-calculation"&gt;agent per-task cost calculation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here’s the uncomfortable part: you can reduce LLM API spend by 30% and still lose the plot if you accidentally build a telemetry firehose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making traces actionable: from spans to SLOs
&lt;/h3&gt;

&lt;p&gt;If your on-call can’t answer these in two minutes, your observability is theater:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What’s p95 end-to-end latency per model?&lt;/li&gt;
&lt;li&gt;Which tenant is burning the most tokens in the last hour?&lt;/li&gt;
&lt;li&gt;What’s the error rate of tool &lt;code&gt;payments.refund&lt;/code&gt; since deploy &lt;code&gt;abc123&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Are retrieval latencies causing model retries?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Turn those into SLOs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm.request&lt;/code&gt; p95 latency &amp;lt; &lt;strong&gt;2.5s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm.cost_usd&lt;/code&gt; per request p95 &amp;lt; &lt;strong&gt;$0.05&lt;/strong&gt; for Tier A tenants&lt;/li&gt;
&lt;li&gt;tool failure rate &amp;lt; &lt;strong&gt;0.1%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then alert on SLO burn, not raw metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping portability while still using a vendor UI
&lt;/h2&gt;

&lt;p&gt;Vendor lock-in doesn’t start with pricing. It starts with the schema.&lt;/p&gt;

&lt;p&gt;If your vendor becomes the only place that “understands” your LLM spans, you’re trapped. The fix is boring (good):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep &lt;strong&gt;OTLP as the canonical export&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;enforce your open schema in the collector&lt;/li&gt;
&lt;li&gt;dual-export: vendor UI + your raw store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you migrate vendors, you keep the app instrumentation and collector config. You switch exporters. That’s it.&lt;/p&gt;

&lt;p&gt;This is the same lesson I learned building org-wide compliance scaffolding at Rise People: &lt;strong&gt;baking compliance into scaffolding beats compliance review at PR time&lt;/strong&gt;. For LLM observability, the “scaffolding” is your collector pipeline and schema contract.&lt;/p&gt;

&lt;p&gt;Here’s the official demo-style walkthrough that pairs well with this post if you want a visual trace of a single request:&lt;/p&gt;

&lt;p&gt;[YOUTUBE:9Eq21irmm8M|LLMOps #7 — Observability and Tracing: Seeing Inside a Single Request]&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step: treat your LLM telemetry like an API contract
&lt;/h2&gt;

&lt;p&gt;My prediction: within 12 months, most teams will have an “LLM telemetry contract” the same way they have an API schema. The teams that don’t will keep paying for debugging via incident calls.&lt;/p&gt;

&lt;p&gt;If you’re building agentic systems, stop treating observability as a vendor feature. Treat it as architecture. Lock in your schema. Put redaction and tail sampling in the collector. Keep OTLP as the source of truth.&lt;/p&gt;

&lt;p&gt;Then go ship features without being afraid of your own logs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/llm-observability-vendor-neutral?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=llm-observability-vendor-neutral" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>clouddevops</category>
    </item>
    <item>
      <title>Agent Evaluation Roadmap for Small Teams [2026]: The 30-Min/Week Plan</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Wed, 02 Sep 2026 00:41:03 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/agent-evaluation-roadmap-for-small-teams-2026-the-30-minweek-plan-5faf</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/agent-evaluation-roadmap-for-small-teams-2026-the-30-minweek-plan-5faf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/agent-evaluation-roadmap-teams" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Agent evaluation is the practice of measuring whether an AI agent reliably completes real tasks under real constraints, not whether it can produce a plausible-looking answer. The misconception is that you need an enterprise “LLMOps platform” or academic-style benchmarks to do it well. You don’t. For a 1–3 person team, you need an &lt;strong&gt;agent evaluation roadmap for small teams&lt;/strong&gt;: a sequence of evals that grows with risk, and stays deterministic enough to run in CI.&lt;/p&gt;

&lt;p&gt;I’m writing this as a &lt;em&gt;meta strategy&lt;/em&gt; post because this site already has deep dives on eval mechanics. What’s been missing is the map. The map is what stops you from doing random eval chores that feel productive but don’t prevent the next incident.&lt;/p&gt;

&lt;p&gt;Here’s the differentiator: I’m going to lay out a &lt;strong&gt;2D roadmap&lt;/strong&gt; (offline vs online vs HITL) × (task success, tool-use correctness, recovery, safety, cost/latency), then give you a &lt;strong&gt;time-boxed operating model&lt;/strong&gt; that fits &lt;strong&gt;30–60 minutes per week&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you can’t run an eval deterministically in CI, it’s not an eval. It’s a demo with extra steps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is AI agent evaluation?
&lt;/h2&gt;

&lt;p&gt;AI agent evaluation is the set of tests and measurements that tell you whether an agent can complete a task end-to-end, &lt;em&gt;including tool calls, state changes, and recovery&lt;/em&gt;, without violating safety and cost constraints.&lt;/p&gt;

&lt;p&gt;A chatbot eval asks: “Was the final answer good?” An agent eval asks: “Did it choose the right tool, pass the right arguments, handle failures, avoid unsafe actions, stay within budget, and escalate to a human when it should?” That difference matters because agents fail in more ways than they succeed.&lt;/p&gt;

&lt;p&gt;In practice, I treat evals as a product surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before shipping&lt;/strong&gt;: stop regressions (CI gates).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After shipping&lt;/strong&gt;: detect drift, tool breakage, and unsafe behavior (production monitoring + reviews).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;During iteration&lt;/strong&gt;: shorten the loop between a prompt/tool change and a confidence signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt;, evals are not optional glue. They’re the steering wheel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an “agent evaluation roadmap” (and why it’s not an eval guide)?
&lt;/h2&gt;

&lt;p&gt;[YOUTUBE:trfUBIDeI1Y|LLM as a Judge: Scaling AI Evaluation Strategies]&lt;/p&gt;

&lt;p&gt;An eval guide tells you &lt;em&gt;how&lt;/em&gt; to write and run tests. A roadmap tells you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which eval types to do first&lt;/strong&gt; (offline vs online vs HITL).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to evaluate&lt;/strong&gt; (agents, not chatbots).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to ignore for now&lt;/strong&gt; so you don’t drown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What artifacts to produce&lt;/strong&gt; so the program keeps running when you’re busy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roadmaps exist because small teams don’t fail from lack of tools. They fail from lack of prioritization.&lt;/p&gt;

&lt;p&gt;Most teams I see jump straight to one of two extremes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“LLM-as-judge everything” (flaky, hard to debug).&lt;/li&gt;
&lt;li&gt;“Online A/B tests only” (slow feedback, expensive, unsafe without guardrails).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The roadmap below is the boring answer. It’s also the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline vs online evaluations (and what you should start with)
&lt;/h2&gt;

&lt;p&gt;Offline evals run on a fixed dataset of tasks and traces. Online evals run against real user traffic in production. HITL (human-in-the-loop) evals involve humans reviewing, approving, or taking over.&lt;/p&gt;

&lt;p&gt;Small teams should start offline because offline is the only place you can get &lt;strong&gt;fast, deterministic regression signals&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The roadmap table: eval phase × what it’s for
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Inputs&lt;/th&gt;
&lt;th&gt;What you measure&lt;/th&gt;
&lt;th&gt;Typical tooling&lt;/th&gt;
&lt;th&gt;Time cost (small team)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Offline evals&lt;/td&gt;
&lt;td&gt;Prevent regressions before deploy&lt;/td&gt;
&lt;td&gt;5–50 real tasks, golden traces&lt;/td&gt;
&lt;td&gt;Pass/fail assertions, rubric scores, tool-call correctness&lt;/td&gt;
&lt;td&gt;Repo + CI, trace capture/diff&lt;/td&gt;
&lt;td&gt;30–60 min/week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Online evals&lt;/td&gt;
&lt;td&gt;Catch drift and real-world failures&lt;/td&gt;
&lt;td&gt;Production logs, holdout cohorts&lt;/td&gt;
&lt;td&gt;Success rate, escalation rate, incident rate, p95 latency&lt;/td&gt;
&lt;td&gt;Observability + flags&lt;/td&gt;
&lt;td&gt;1–3 hrs/week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HITL evals&lt;/td&gt;
&lt;td&gt;Reduce blast radius + create training data&lt;/td&gt;
&lt;td&gt;Review queues, approvals, audits&lt;/td&gt;
&lt;td&gt;Approval rate, override reasons, policy violations&lt;/td&gt;
&lt;td&gt;Review UI + audit logs&lt;/td&gt;
&lt;td&gt;1–2 hrs/week&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Offline first. Online second. HITL whenever the agent can cause damage.&lt;/p&gt;

&lt;p&gt;If you want the nuts-and-bolts “how to start” playbook, my deeper post is here: &lt;a href="https://dev.to/blog/start-ai-agent-evaluation-program"&gt;How to Start an AI Agent Evaluation Program (5-Task Scorecard)&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to evaluate (agents, not chatbots)
&lt;/h2&gt;

&lt;p&gt;Rishav Singh’s production-oriented scorecard framing is the cleanest mental model I’ve seen: seven dimensions that actually map to failures you’ll see in production. I use it as the “coverage map” for small teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Task success&lt;/li&gt;
&lt;li&gt;Grounding&lt;/li&gt;
&lt;li&gt;Tool correctness&lt;/li&gt;
&lt;li&gt;Recovery&lt;/li&gt;
&lt;li&gt;Safety/permissions&lt;/li&gt;
&lt;li&gt;Cost/latency&lt;/li&gt;
&lt;li&gt;Human handoff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those categories are broad enough to be stable, and specific enough to turn into checks.&lt;/p&gt;

&lt;p&gt;Now the opinionated part: &lt;strong&gt;don’t start by scoring “response quality.”&lt;/strong&gt; Start by scoring &lt;em&gt;tool and state correctness&lt;/em&gt;. In agent systems, “nice prose” is a rounding error compared to “deleted the wrong record” or “emailed the wrong customer.”&lt;/p&gt;

&lt;p&gt;Concrete examples of what I mean by “agent” eval targets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool selection and arguments&lt;/strong&gt;: did it call &lt;code&gt;create_invoice(customer_id=...)&lt;/code&gt; or hallucinate &lt;code&gt;createInvoice&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State changes&lt;/strong&gt;: did it write the right record, exactly once?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery&lt;/strong&gt;: did it stop after a 429 or spin in a retry loop?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions&lt;/strong&gt;: did it attempt a privileged tool without approval?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building tool-using agents, also read: &lt;a href="https://dev.to/blog/agent-tool-call-failure-testing"&gt;agent tool-use regression testing&lt;/a&gt;. That post is a deep dive on breaking tools on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: a no-platform eval harness (repo + CI)
&lt;/h2&gt;

&lt;p&gt;You don’t need a vendor platform to start. A “no-platform harness” is just:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A repo folder with your &lt;strong&gt;task bank&lt;/strong&gt; (inputs + expected outcomes).&lt;/li&gt;
&lt;li&gt;A runner that can &lt;strong&gt;replay&lt;/strong&gt; tasks against a pinned agent version.&lt;/li&gt;
&lt;li&gt;Normalization + &lt;strong&gt;golden traces&lt;/strong&gt; so you can diff behavior.&lt;/li&gt;
&lt;li&gt;CI wiring so it runs on every PR and on a weekly schedule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built a multi-agent publishing pipeline for this site, and one lesson carried over hard: &lt;strong&gt;deterministic gates before LLM review catch more than doubling the review model’s size.&lt;/strong&gt; That’s true for blog publishing and it’s true for &lt;a href="https://dev.to/pillars/production-ai"&gt;production AI&lt;/a&gt;. If your pipeline can’t fail fast and deterministically, you’re debugging vibes.&lt;/p&gt;

&lt;p&gt;Practically, “deterministic enough for CI” means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fix random seeds where you can.&lt;/li&gt;
&lt;li&gt;Pin model versions (or route through a fixed alias).&lt;/li&gt;
&lt;li&gt;Stub tools for offline runs.&lt;/li&gt;
&lt;li&gt;Cap token budgets and tool-call counts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to go deeper on keeping systems observable, this pairs well with: &lt;a href="https://dev.to/blog/llm-observability-metrics"&gt;LLM observability metrics&lt;/a&gt; and &lt;a href="https://dev.to/blog/opentelemetry-ai-agents-instrumentation"&gt;OpenTelemetry instrumentation for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing evals: pass/fail assertions
&lt;/h2&gt;

&lt;p&gt;Pass/fail assertions are the backbone of small-team evals because they force you to define success unambiguously.&lt;/p&gt;

&lt;p&gt;My rule: &lt;strong&gt;assert on artifacts, not prose&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Examples of assertions that don’t turn into religious wars:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool calls are from an allowlist and match a JSON schema.&lt;/li&gt;
&lt;li&gt;The agent produced an idempotency key for state-changing tools.&lt;/li&gt;
&lt;li&gt;The final state matches expected DB rows.&lt;/li&gt;
&lt;li&gt;The agent escalated to a human when policy required it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LLM-as-judge can help with subjective checks, but it should be layered on top of hard checks. If you start with judges, you’ll end up with flaky tests and no idea what changed.&lt;/p&gt;

&lt;p&gt;This is where &lt;a href="https://dev.to/glossary/agent-framework"&gt;agent framework&lt;/a&gt; choice matters less than people think. Whether you’re using LangGraph, AutoGen, or something custom, you still need deterministic assertions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running evals in CI / regression tests
&lt;/h2&gt;

&lt;p&gt;A small-team cadence that works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On every PR&lt;/strong&gt;: run the 5-task “smoke suite.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly&lt;/strong&gt;: run the full regression suite and review diffs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release gate&lt;/strong&gt;: block deploys if safety/cost checks fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From my DEV post, a minimal program can start with &lt;strong&gt;~5 real tasks&lt;/strong&gt; and a &lt;strong&gt;weekly CI regression cadence&lt;/strong&gt;, with a layered approach: deterministic hard checks, rubric-based judge, and periodic human spot checks.&lt;/p&gt;

&lt;p&gt;If you’re looking for adjacent patterns, I’ve found the same gating discipline applies outside AI too. My &lt;a href="https://dev.to/blog/code-review-automation-defaults"&gt;7 safer defaults for code review automation&lt;/a&gt; is basically the non-AI cousin of “make regressions hard to merge.”&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you keep evals deterministic enough for CI?
&lt;/h3&gt;

&lt;p&gt;Three tactics that actually work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tool stubs&lt;/strong&gt;: replace real APIs with deterministic fakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Golden traces&lt;/strong&gt;: compare normalized traces, not raw text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget guardrails&lt;/strong&gt;: stop conditions for cost and infinite loops.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your agent touches external systems, deterministic simulation matters even more. That’s why I separate “general eval” from “tool-call failure testing.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Golden traces / trace diffing (concept)
&lt;/h2&gt;

&lt;p&gt;Golden traces are the missing piece in most small-team eval programs.&lt;/p&gt;

&lt;p&gt;Instead of asking “did the final answer match?”, you store a canonical representation of what the agent did:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tool name + arguments&lt;/li&gt;
&lt;li&gt;tool response (normalized)&lt;/li&gt;
&lt;li&gt;state transitions&lt;/li&gt;
&lt;li&gt;retry/backoff decisions&lt;/li&gt;
&lt;li&gt;stop reason&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then you diff today’s trace against last week’s. When something changes, you &lt;em&gt;see what changed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is also how you keep LLM-judge systems honest. Judges drift. Prompts change. Model routing changes. Trace diffing is the anchor.&lt;/p&gt;

&lt;p&gt;If you want a deep dive on this approach for reliability, see: &lt;a href="https://dev.to/blog/agent-tool-call-failure-testing"&gt;How to Do Agent Tool Call Failure Testing [2026 CI Harness]&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety and permissions considerations
&lt;/h2&gt;

&lt;p&gt;Small teams consistently under-invest here because it feels like “enterprise overhead.” That’s a mistake.&lt;/p&gt;

&lt;p&gt;Two rules I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any tool that can cause irreversible damage is a &lt;strong&gt;privileged tool&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Privileged tools require either &lt;strong&gt;HITL approval&lt;/strong&gt; or &lt;strong&gt;very tight deterministic constraints&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This maps cleanly to the OWASP GenAI LLM Top 10, which is now maintained under the OWASP GenAI Security Project. The official archive page is here: &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/" rel="noopener noreferrer"&gt;OWASP Foundation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On this site, I treat agent security as its own surface area. If you’re building anything that can be attacked via instructions, read: &lt;a href="https://dev.to/blog/prompt-injection-2026-owasp-llm-vulnerability"&gt;prompt injection&lt;/a&gt;, &lt;a href="https://dev.to/pillars/ai-security-safety"&gt;AI security&lt;/a&gt;, and &lt;a href="https://dev.to/blog/ai-security-leader-playbook"&gt;AI security leader playbook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A concrete, testable safety eval: ensure the agent refuses to call &lt;code&gt;delete_*&lt;/code&gt; tools without an approval token. That’s not philosophy. That’s an assertion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and latency considerations
&lt;/h2&gt;

&lt;p&gt;Cost and latency are not “ops metrics.” They are part of correctness.&lt;/p&gt;

&lt;p&gt;If an agent is correct but burns $4 in tokens per task, it’s broken. If it’s correct but takes 45 seconds, users will abandon it and you’ll get pressure to remove guardrails.&lt;/p&gt;

&lt;p&gt;So your eval program needs budget ceilings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;max tool calls per task (e.g., &lt;strong&gt;&amp;lt;= 8&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;max retries per tool (e.g., &lt;strong&gt;&amp;lt;= 2&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;max tokens per task (define a ceiling)&lt;/li&gt;
&lt;li&gt;p95 latency target (define a ceiling)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to get specific about the math, I’ve written about &lt;a href="https://dev.to/glossary/llm-cost"&gt;LLM cost&lt;/a&gt; and agent costs: &lt;a href="https://dev.to/blog/ai-agent-cost-per-task-2026"&gt;AI agent cost per task&lt;/a&gt; and &lt;a href="https://dev.to/blog/agent-per-task-cost-calculation"&gt;agent per-task cost calculation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One data anchor from my own work: running this blog’s 7-agent pipeline taught me that &lt;strong&gt;idempotent, per-step keys&lt;/strong&gt; matter because retries are inevitable. The same lesson applies to agent tools. If you don’t have idempotency, “recovery” becomes “double-charge the customer.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Human handoff / HITL
&lt;/h2&gt;

&lt;p&gt;Human-in-the-loop is not a crutch. It’s a design tool.&lt;/p&gt;

&lt;p&gt;Define explicit handoff criteria:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;uncertainty above threshold&lt;/li&gt;
&lt;li&gt;privileged action requested&lt;/li&gt;
&lt;li&gt;repeated tool failures&lt;/li&gt;
&lt;li&gt;policy-sensitive content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then evaluate the handoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was the handoff triggered when it should have been?&lt;/li&gt;
&lt;li&gt;Did the agent include enough context for a human to take over quickly?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need patterns you can copy, I’ve already written: &lt;a href="https://dev.to/blog/tool-approval-patterns-ai-agents"&gt;10 HITL tool approval patterns for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you design an escalation path?
&lt;/h3&gt;

&lt;p&gt;Treat escalation like an on-call policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Define a “stop and ask” boundary.&lt;/li&gt;
&lt;li&gt;Define who gets paged or queued.&lt;/li&gt;
&lt;li&gt;Log the reason codes so you can reduce unnecessary escalations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without reason codes, HITL becomes expensive theater.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimum viable program (Day 0 → Week 2 → Month 2)
&lt;/h2&gt;

&lt;p&gt;This is the part most guides skip. Here’s what I’d actually do with 1–3 people.&lt;/p&gt;

&lt;h3&gt;
  
  
  Day 0 (today): 90 minutes, no excuses
&lt;/h3&gt;

&lt;p&gt;Artifacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 5-task bank: 5 real tasks from last week’s conversations.&lt;/li&gt;
&lt;li&gt;A scorecard with 7 dimensions (success/tool/recovery/safety/cost/handoff).&lt;/li&gt;
&lt;li&gt;A runner that can execute those tasks offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ritual:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One owner. If everyone owns it, nobody owns it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Week 2: make it a CI habit
&lt;/h3&gt;

&lt;p&gt;Artifacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CI job runs on every PR.&lt;/li&gt;
&lt;li&gt;Golden traces for the 5 tasks.&lt;/li&gt;
&lt;li&gt;A “release gate” check for safety + budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ritual:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;30 minutes weekly to review diffs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Month 2: add online + HITL where it matters
&lt;/h3&gt;

&lt;p&gt;Artifacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Holdout cohort for online metrics.&lt;/li&gt;
&lt;li&gt;HITL approval queue for privileged tools.&lt;/li&gt;
&lt;li&gt;A versioning scheme for prompts/tools/judges.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ritual:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monthly “eval drift” review: which tasks are stale, which new failures showed up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also where you should start reading your own incident logs. If you don’t have one, you don’t have a reliability program.&lt;/p&gt;

&lt;h2&gt;
  
  
  Program-level anti-patterns (the stuff that kills evals)
&lt;/h2&gt;

&lt;p&gt;I’ve seen the same failure patterns repeat across teams, regardless of stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard theatre&lt;/strong&gt;: beautiful charts, no gating decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metric monoculture&lt;/strong&gt;: optimizing one number while regressions hide elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flaky online tests&lt;/strong&gt; without holdouts and without a rollback plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge drift&lt;/strong&gt;: changing models/prompts without versioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset staleness&lt;/strong&gt;: tasks that no longer match reality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want one north star: evals should change shipping behavior. If nobody blocks a release because an eval failed, you’re cosplaying QA.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next (the hub-and-spoke plan)
&lt;/h2&gt;

&lt;p&gt;This post is meant to be the hub. The spokes already exist on this site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start here for the step-by-step program: &lt;a href="https://dev.to/blog/start-ai-agent-evaluation-program"&gt;minimal agent evaluation program&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Go deep on reliability regression: &lt;a href="https://dev.to/blog/agent-tool-call-failure-testing"&gt;agent tool-use regression testing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Build better gates across prompts/tools/RAG: &lt;a href="https://dev.to/blog/ai-engineering-evals-gates"&gt;AI engineering evals&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lock down safety testing: &lt;a href="https://dev.to/blog/prompt-injection-regression-testing-ci"&gt;prompt injection regression testing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Make your agent observable enough to debug: &lt;a href="https://dev.to/blog/ai-agent-observability-logging-schema"&gt;AI agent observability logging schema&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One external reference I actually recommend bookmarking: the &lt;a href="https://github.com/openai/evals" rel="noopener noreferrer"&gt;OpenAI Evals&lt;/a&gt; repository is a good example of “evals as code” thinking, even if you don’t adopt it directly.&lt;/p&gt;

&lt;p&gt;Here’s my prediction: within a year, “agent evaluation” will stop being a niche LLMOps topic and become a default expectation, like unit tests. The teams that win won’t be the ones with the fanciest dashboards. They’ll be the ones that can run a five-task regression in CI and trust the result.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/agent-evaluation-roadmap-teams?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=agent-evaluation-roadmap-teams" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>evals</category>
      <category>llmops</category>
      <category>testing</category>
    </item>
    <item>
      <title>Design to Code Tools Benchmark [2026]: 5 Tools, Real Diffs</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Tue, 01 Sep 2026 12:42:55 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/design-to-code-tools-benchmark-2026-5-tools-real-diffs-1cak</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/design-to-code-tools-benchmark-2026-5-tools-real-diffs-1cak</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/design-to-code-tools-benchmark" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You don’t buy a design-to-code tool because it can recreate a hero section. You buy it because a designer just shipped a &lt;em&gt;messy&lt;/em&gt; SaaS dashboard in Figma, your team needs it in React/Next.js by Friday, and you’d like to avoid spending the weekend untangling &lt;code&gt;div&lt;/code&gt; soup.&lt;/p&gt;

&lt;p&gt;That’s why this &lt;strong&gt;design to code tools benchmark&lt;/strong&gt; uses an outdated, real-world dashboard layout as the test case. Same Figma export. Same component rules. Five tools. And a scorecard that cares about what engineering teams actually pay for later: accessibility, semantics, bundle size, and how painful it is to make changes after generation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most design-to-code tools can hit “looks right” in 2026, but they still miss “edits cleanly” by a mile.&lt;/li&gt;
&lt;li&gt;Accessibility failures are consistent and predictable. You can measure them, and you should.&lt;/li&gt;
&lt;li&gt;Component mapping is the real differentiator. Screenshot-to-code is fast, but it leaks tech debt.&lt;/li&gt;
&lt;li&gt;Bundle size is a hidden tax. Two tools produced codebases with 2–3x dependency footprint for the same UI.&lt;/li&gt;
&lt;li&gt;If you can’t reproduce the output, you don’t have a benchmark. You have content marketing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is a design-to-code tool (and what it isn’t)
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;design-to-code tool&lt;/strong&gt; is software that converts a design source (usually a Figma file or export) into executable UI code (often React/Next.js) with some attempt at reusable components, styles, and layout constraints.&lt;/p&gt;

&lt;p&gt;What it isn’t: a magical replacement for frontend engineering.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth is that there are two completely different product categories wearing the same label:&lt;/p&gt;

&lt;p&gt;1) &lt;strong&gt;Deterministic translators&lt;/strong&gt;: token and component aware. They try to map Figma variables, components, and layout constraints into your codebase’s primitives.&lt;/p&gt;

&lt;p&gt;2) &lt;strong&gt;Screenshot-to-code generators&lt;/strong&gt;: LLM-vision driven. They infer structure from pixels and “best guess” their way to JSX and CSS.&lt;/p&gt;

&lt;p&gt;Both can be useful. But if you’re choosing for production, you should treat them like you’d treat two different database classes. They fail differently.&lt;/p&gt;

&lt;p&gt;In 2026, Dev Mode adoption and token pipelines mean the &lt;em&gt;inputs&lt;/em&gt; are finally structured enough to do better than pixels. That’s what this benchmark is trying to reward.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we set up the benchmark (Figma file, rules, versions, prompts/settings)
&lt;/h2&gt;

&lt;p&gt;Most comparisons cheat in three ways: pristine designs, hidden prompts/settings, and no way to reproduce anything.&lt;/p&gt;

&lt;p&gt;[YOUTUBE:BOl05zmQjOg|Stop Wasting Dev Time on Frontend: Figma to Code in 8 Minutes]&lt;/p&gt;

&lt;p&gt;So the harness is boring on purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  The “messy SaaS dashboard” test design
&lt;/h3&gt;

&lt;p&gt;The test case is a single screen: an old-school B2B admin dashboard.&lt;/p&gt;

&lt;p&gt;It has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nested Auto Layouts&lt;/li&gt;
&lt;li&gt;inconsistent naming&lt;/li&gt;
&lt;li&gt;a legacy sidebar + top nav&lt;/li&gt;
&lt;li&gt;dense tables&lt;/li&gt;
&lt;li&gt;forms with error states&lt;/li&gt;
&lt;li&gt;a couple of modal/popover interactions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words: the thing you actually ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules (the constraints every tool got)
&lt;/h3&gt;

&lt;p&gt;To make this a fair &lt;strong&gt;figma to react code generator comparison&lt;/strong&gt;, I enforced the same constraints across tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Target framework: &lt;strong&gt;Next.js App Router&lt;/strong&gt; (React 19-era patterns where supported)&lt;/li&gt;
&lt;li&gt;Styling: allow tool default, but record which approach it chose (Tailwind, CSS Modules, styled-components, inline styles)&lt;/li&gt;
&lt;li&gt;Component rules: 

&lt;ul&gt;
&lt;li&gt;sidebar, top nav, table, card, button, input must be components&lt;/li&gt;
&lt;li&gt;no component may exceed &lt;strong&gt;250 lines&lt;/strong&gt; without a good reason&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Accessibility baseline: keyboard reachable navigation, form labels, table semantics&lt;/li&gt;
&lt;li&gt;No hand-fixing before scoring. The raw output is what gets graded first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Harness outputs and metrics
&lt;/h3&gt;

&lt;p&gt;Each tool produced a fresh repo (or export) that I normalized into a common folder structure so the metric collectors could run.&lt;/p&gt;

&lt;p&gt;Metrics collected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A11y&lt;/strong&gt;: &lt;code&gt;axe-core&lt;/code&gt; scan + Lighthouse accessibility score&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantics&lt;/strong&gt;: semantic tag ratio (e.g. &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;thead&lt;/code&gt;, &lt;code&gt;th&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;nav&lt;/code&gt;, &lt;code&gt;main&lt;/code&gt;) vs &lt;code&gt;div/span&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle footprint&lt;/strong&gt;: production build output + dependency count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editability&lt;/strong&gt;: three change requests applied and measured (files touched + LOC delta)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual fidelity&lt;/strong&gt;: human-graded on a 1–10 rubric (layout, spacing, typography, states)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’ve read my harness-style posts like &lt;a href="https://dev.to/blog/ai-voice-detector-accuracy-test"&gt;How to Run an AI Voice Detector Accuracy Test [2026 Harness]&lt;/a&gt; or &lt;a href="https://dev.to/blog/agent-evaluation-harness-replay"&gt;Agent Evaluation Harness [2026]: Replay, Rubrics, CI Gates&lt;/a&gt;, this is the same mindset: lock inputs, log everything, make it repeatable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three “post-generation change requests”
&lt;/h3&gt;

&lt;p&gt;Because the missing metric in this space is edit cost, every codebase had to implement:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;“Add a collapsed sidebar state with persisted preference.”&lt;/li&gt;
&lt;li&gt;“Table: add a selectable row checkbox column + bulk action bar.”&lt;/li&gt;
&lt;li&gt;“Form: add inline validation messages + focus on first error.”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For each, I tracked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;number of files touched&lt;/li&gt;
&lt;li&gt;net LOC changed&lt;/li&gt;
&lt;li&gt;whether changes were localized (good components) or global (fragile structure)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reproducibility: what you should publish
&lt;/h3&gt;

&lt;p&gt;If you’re publishing your own &lt;strong&gt;design to code benchmark methodology&lt;/strong&gt;, here’s the minimum viable disclosure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Figma export (or a sanitized equivalent)&lt;/li&gt;
&lt;li&gt;tool versions and settings&lt;/li&gt;
&lt;li&gt;prompts (if any)&lt;/li&gt;
&lt;li&gt;the metric scripts&lt;/li&gt;
&lt;li&gt;raw outputs (or at least diffs)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No raw artifacts means no trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools included + why these five
&lt;/h2&gt;

&lt;p&gt;I picked five tools to cover the spectrum teams actually evaluate in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Figma Dev Mode baseline (control)&lt;/strong&gt;: not a generator, but the structured handoff baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A token-aware exporter&lt;/strong&gt;: claims deterministic mapping from variables/components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A component-mapping AI tool&lt;/strong&gt;: “LLM assist, but constrained.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pure screenshot-to-code model&lt;/strong&gt;: fastest path to pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A “full-stack” design-to-app platform&lt;/strong&gt;: includes routing, state, and data mocks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’m intentionally not pretending the list is “the only five that matter.” The point is the harness. You can swap in your preferred vendor and keep the scoring.&lt;/p&gt;

&lt;p&gt;If you want a related buyer framework for AI tooling broadly, I’ve written about how evaluation breaks down in practice in &lt;a href="https://dev.to/blog/ai-coding-assistant-reviews-2026"&gt;AI Coding Assistant Reviews 2026: The Only Buyer Framework That Holds Up&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation criteria (a11y, semantics, performance/bundle, editability/maintainability, visual fidelity)
&lt;/h2&gt;

&lt;p&gt;Here’s the stance: &lt;strong&gt;visual fidelity is table stakes&lt;/strong&gt;. You don’t get bonus points for matching pixels if you shipped unusable HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Accessibility (a11y)
&lt;/h3&gt;

&lt;p&gt;This is the easiest category to measure and the most ignored.&lt;/p&gt;

&lt;p&gt;If you generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unlabeled inputs&lt;/li&gt;
&lt;li&gt;clickable &lt;code&gt;div&lt;/code&gt;s&lt;/li&gt;
&lt;li&gt;broken focus order&lt;/li&gt;
&lt;li&gt;modals without focus trap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…you didn’t save time. You created a backlog item that will show up during a compliance push, an enterprise deal, or a lawsuit.&lt;/p&gt;

&lt;p&gt;I scored a11y with two independent signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;axe-core&lt;/code&gt; violation count (lower is better)&lt;/li&gt;
&lt;li&gt;Lighthouse accessibility score (higher is better)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete example: in the dashboard table, any generator that used &lt;code&gt;div&lt;/code&gt; grids instead of &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; + &lt;code&gt;&amp;lt;th scope&amp;gt;&lt;/code&gt; took a hit. That is not “opinion.” It changes how screen readers interpret the UI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Semantics (div soup vs real HTML)
&lt;/h3&gt;

&lt;p&gt;Do design-to-code tools generate semantic HTML or div soup? Most still generate div soup.&lt;/p&gt;

&lt;p&gt;I used a rough-but-useful ratio: semantic elements and attributes vs generic containers.&lt;/p&gt;

&lt;p&gt;Concrete checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;navigation uses &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;main content uses &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;forms use &lt;code&gt;&amp;lt;label for&amp;gt;&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;tables use &lt;code&gt;&amp;lt;table&amp;gt;&amp;lt;thead&amp;gt;&amp;lt;tbody&amp;gt;&amp;lt;th&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Performance and bundle footprint
&lt;/h3&gt;

&lt;p&gt;The hidden costs (bundle size, dependencies, lock-in) show up here.&lt;/p&gt;

&lt;p&gt;I measured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;direct dependency count (from &lt;code&gt;package.json&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;production JS size output (Next.js build artifacts)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete example: two tools produced almost the same UI, but one pulled in a full component library + icon pack + CSS-in-JS runtime. Dependency footprint matters because it increases security surface area and upgrade tax. If you care about this class of risk, the mindset is similar to what I wrote in &lt;a href="https://dev.to/blog/npm-supply-chain-attack-defense"&gt;NPM supply chain attacks in 2026: Why libraries are prime targets&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Editability and maintainability
&lt;/h3&gt;

&lt;p&gt;This is the category that decides whether you keep the tool.&lt;/p&gt;

&lt;p&gt;I scored:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;component boundaries (are they real, or just files?)&lt;/li&gt;
&lt;li&gt;prop design (can you change a variant without rewriting?)&lt;/li&gt;
&lt;li&gt;folder structure (can a new engineer find anything?)&lt;/li&gt;
&lt;li&gt;diff quality for the three change requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete example: “collapsed sidebar” should be one stateful wrapper + a few class toggles. If it requires editing 14 files and 400 LOC, the generator failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Visual fidelity
&lt;/h3&gt;

&lt;p&gt;Yes, you still need it.&lt;/p&gt;

&lt;p&gt;But I scored it last. A tool that’s a 9/10 on fidelity and a 2/10 on a11y is not “production ready.” It’s a prototype generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results summary table (scores + quick takeaways)
&lt;/h2&gt;

&lt;p&gt;Below is the benchmark table. Scores are 1–10. Higher is better.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool category&lt;/th&gt;
&lt;th&gt;A11y&lt;/th&gt;
&lt;th&gt;Semantics&lt;/th&gt;
&lt;th&gt;Bundle/Deps&lt;/th&gt;
&lt;th&gt;Editability&lt;/th&gt;
&lt;th&gt;Fidelity&lt;/th&gt;
&lt;th&gt;Quick take&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dev Mode baseline (hand-coded from structured handoff)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Slowest to start, fastest to maintain.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token-aware exporter&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Best “boring engineering” output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component-mapping AI tool&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Great pixels, medium structure, fixable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot-to-code generator&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Looks right. Everything else hurts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-stack design-to-app platform&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Heavy runtime and opinions. Useful for demos.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jumped out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The screenshot-first tool topped fidelity. It also produced the most brittle code by far.&lt;/li&gt;
&lt;li&gt;Deterministic inputs (tokens/components) correlated with better editability. That’s the direction this industry should be running in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Per-tool deep dive (strengths/weaknesses, code samples)
&lt;/h2&gt;

&lt;p&gt;I’m keeping code samples short on purpose. This isn’t a tutorial. You don’t win by copy-pasting generated JSX. You win by understanding the failure modes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Dev Mode baseline: the control that everyone ignores
&lt;/h3&gt;

&lt;p&gt;Dev Mode is what happens when you stop fantasizing about “AI that understands design” and just give engineers structured data.&lt;/p&gt;

&lt;p&gt;The baseline repo had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;actual semantic landmarks&lt;/li&gt;
&lt;li&gt;sensible component boundaries&lt;/li&gt;
&lt;li&gt;minimal dependencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade is obvious: it takes longer to get the first render.&lt;/p&gt;

&lt;p&gt;The other trade is less obvious: it’s the only output that didn’t fight me on change request #2 (table selection + bulk actions). That work is 80% about component structure, not pixels.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Token-aware exporter: the closest thing to “real” design-to-code
&lt;/h3&gt;

&lt;p&gt;This was the most production-friendly generated output.&lt;/p&gt;

&lt;p&gt;It did three things right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;kept styles consistent by leaning on variables&lt;/li&gt;
&lt;li&gt;produced fewer mystery wrappers&lt;/li&gt;
&lt;li&gt;created reusable components that didn’t collapse under edits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode: it still struggled with complex responsive constraints in the table header and filter bar. You get a working UI, but you still need a senior engineer pass.&lt;/p&gt;

&lt;p&gt;If you’re serious about tokens, treat “tokens → code” as CI, not a one-off export. That’s a workflow point more than a tool point.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Component-mapping AI tool: good pixels, acceptable structure
&lt;/h3&gt;

&lt;p&gt;This is the class of tool most teams want: “use AI, but respect my component library.”&lt;/p&gt;

&lt;p&gt;It did well on fidelity and got “okay” on editability.&lt;/p&gt;

&lt;p&gt;Its biggest issue was &lt;strong&gt;prop design&lt;/strong&gt;. Buttons, inputs, and cards were components, but variants were hard-coded. That makes change requests painful because you end up duplicating components instead of parameterizing them.&lt;/p&gt;

&lt;p&gt;This is where an agentic refactor can help. Generate, then let a coding agent reshape the code into your architecture. If you’re experimenting with that workflow, my posts on &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; and &lt;a href="https://dev.to/pillars/developer-tools-workflow"&gt;agent framework&lt;/a&gt; patterns are the broader context.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Screenshot-to-code generator: the div soup champion
&lt;/h3&gt;

&lt;p&gt;It was fast. It was pretty. It was also the least reusable output.&lt;/p&gt;

&lt;p&gt;Common problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clickable &lt;code&gt;div&lt;/code&gt; instead of &lt;code&gt;button&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;no form labeling discipline&lt;/li&gt;
&lt;li&gt;“CSS by accident” (a cascade of absolute/relative hacks)&lt;/li&gt;
&lt;li&gt;duplicated UI chunks instead of components&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Change request #1 (collapsed sidebar) was the killer. The sidebar was visually correct, but it wasn’t a component with a layout contract. It was just a pile of nested containers. You can’t edit that cleanly.&lt;/p&gt;

&lt;p&gt;If you’re using this category, treat it like a throwaway prototype generator. Don’t pretend it’s your codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Full-stack design-to-app platform: heavy, opinionated, sometimes worth it
&lt;/h3&gt;

&lt;p&gt;This class ships a lot: routing, state, mock data, auth-ish scaffolding.&lt;/p&gt;

&lt;p&gt;That’s also the problem.&lt;/p&gt;

&lt;p&gt;The bundle footprint was the worst of the five because it pulled in a runtime that assumes you’ll stay inside its world. This is where lock-in creeps in. You “saved” 2 days of engineering time and bought a year of migration risk.&lt;/p&gt;

&lt;p&gt;For a designer-led prototype sprint, it’s still useful.&lt;/p&gt;

&lt;p&gt;For a Next.js production app with CI/CD, tests, and long-term ownership, it’s a bet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommendations by persona (startup, enterprise, designer-led, dev-led)
&lt;/h2&gt;

&lt;p&gt;This is where I’ll be direct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Startup shipping a v1 fast
&lt;/h3&gt;

&lt;p&gt;Use a screenshot-to-code tool if you’re honest that it’s a prototype. Then rewrite the UI layer once you have real usage.&lt;/p&gt;

&lt;p&gt;If you can’t afford that rewrite, pick the token-aware exporter category instead. “Pretty now, painful forever” is not a bargain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enterprise with compliance and accessibility requirements
&lt;/h3&gt;

&lt;p&gt;Avoid screenshot-to-code as your primary path.&lt;/p&gt;

&lt;p&gt;Start with deterministic pipelines (tokens, components) and treat generation as scaffolding that engineers own. If your org cares about compliance, you already know: accessibility is a product requirement, not a polish pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  Designer-led teams (design systems are strong)
&lt;/h3&gt;

&lt;p&gt;Invest in token pipelines and component mapping. Your design maturity is the input these tools need.&lt;/p&gt;

&lt;p&gt;This is where Figma variables and Dev Mode pay off. You’re not trying to generate “code.” You’re trying to enforce system consistency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dev-led teams (component library already exists)
&lt;/h3&gt;

&lt;p&gt;Pick a tool that can map to your components. If it can’t, you’ll waste more time deleting generated UI than writing it.&lt;/p&gt;

&lt;p&gt;Also: measure hidden costs. Bundle size and dependencies are not abstract concerns. They show up in build times, security reviews, and runtime bugs. If web performance is your north star, you’ll like the thinking in &lt;a href="https://dev.to/blog/javascript-bloat-causes-fixes"&gt;JavaScript bloat in 2026: 3 architectural root causes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations + how to reproduce
&lt;/h2&gt;

&lt;p&gt;A benchmark like this can be honest and still be incomplete.&lt;/p&gt;

&lt;p&gt;Limitations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One design file is not the entire universe. It’s one representative stress test.&lt;/li&gt;
&lt;li&gt;I didn’t test mobile-first flows deeply. Dashboards skew desktop.&lt;/li&gt;
&lt;li&gt;Some tools optimize for their own runtime or hosting platform. I treated that as a cost, but it may be a feature for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How to reproduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the same design class: messy, nested, real.&lt;/li&gt;
&lt;li&gt;Freeze versions and settings.&lt;/li&gt;
&lt;li&gt;Export five codebases.&lt;/li&gt;
&lt;li&gt;Run the same metric collectors.&lt;/li&gt;
&lt;li&gt;Apply the same three change requests and measure diffs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’ve built evaluation programs for AI coding tools, this should feel familiar. The mechanics are similar to what I recommend in &lt;a href="https://dev.to/blog/start-ai-agent-evaluation-program"&gt;How to Start an AI Agent Evaluation Program (5-Task Scorecard)&lt;/a&gt; and &lt;a href="https://dev.to/blog/ai-engineering-evals-gates"&gt;AI Engineering Evals: Regression Gates for Prompts, Tools, RAG [2026]&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My prediction: by 2027, the winning design-to-code vendors won’t market “generate UI from Figma.” They’ll sell &lt;strong&gt;a reproducible pipeline&lt;/strong&gt; that ties Figma variables, component registries, accessibility checks, and diffs into CI. If your tool can’t survive a diff, it’s not a workflow. It’s a demo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/design-to-code-tools-benchmark?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=design-to-code-tools-benchmark" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>figma</category>
      <category>frontend</category>
      <category>aitools</category>
      <category>react</category>
    </item>
    <item>
      <title>How to Run an AI Voice Detector Accuracy Test [2026 Harness]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:44:45 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-run-an-ai-voice-detector-accuracy-test-2026-harness-2435</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-run-an-ai-voice-detector-accuracy-test-2026-harness-2435</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/ai-voice-detector-accuracy-test" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  How to Run an AI Voice Detector Accuracy Test [2026 Harness]
&lt;/h1&gt;

&lt;p&gt;You can set up a repeatable &lt;strong&gt;ai voice detector accuracy test&lt;/strong&gt; harness in about 60–90 minutes. After that, it’s basically a push-button benchmark you can rerun monthly. Deterministic splits. Detector versions pinned. A transformation matrix (phone-call audio, re-recording, compression). And a “public artifacts” bundle that lets you prove your numbers without uploading your actual voice.&lt;/p&gt;

&lt;p&gt;I’m writing this because most “AI voice detector” claims are marketing, not measurement. If a detector faceplants after &lt;strong&gt;one&lt;/strong&gt; codec change, or the vendor won’t explain their threshold policy, you’re not buying security. You’re buying vibes.&lt;/p&gt;

&lt;p&gt;One more reason I’m bullish on this topic: based on my internal Google Search Console tooling for this site, we’re already sitting on page 2 for “ai voice detector” (avg position ~12.8 over 90 days) with &lt;strong&gt;~2,816 impressions&lt;/strong&gt;. The broader query neighborhood is roughly &lt;strong&gt;~12,000 searches/month across ~230 related queries&lt;/strong&gt; where kunalganglani.com appears. People want a harness they can run, not another “Top 10 deepfake tools” list. (And yes, this is intentionally different from my existing &lt;a href="https://dev.to/blog/ai-voice-detector-detect-audio"&gt;AI voice detector&lt;/a&gt; roundup. That post is the map. This one is the test rig.)&lt;/p&gt;

&lt;h2&gt;
  
  
  AI voice detector accuracy test (repeatable) — 7 steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick your threat model&lt;/strong&gt; (call center fraud vs content moderation vs account recovery). Write down the false-positive cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a dataset manifest&lt;/strong&gt; (not just a folder of WAVs). Hash every file. Record provenance, generator family, codec, mic/channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create deterministic splits&lt;/strong&gt; (train/dev/test) that avoid leakage across speakers, scripts, and clone vendors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run detectors in a sealed pipeline&lt;/strong&gt; (same preprocessing, same sample-rate policy, version pinned).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure beyond “accuracy”&lt;/strong&gt;: ROC/AUC, EER, precision/recall at your chosen threshold, and calibration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stress test robustness&lt;/strong&gt; with transformations: phone bandlimit, Opus/AAC/MP3, re-recording, noise, speed/pitch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish results without doxxing yourself&lt;/strong&gt;: release hashes + metadata + aggregate scores + a reproducible runner. Keep raw personal audio private.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What is an AI voice detector (synthetic speech / deepfake audio detection)
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;AI voice detector&lt;/strong&gt; is a model or heuristic system that classifies an audio clip as human speech vs synthetic/modified speech (TTS, voice conversion, cloning). Usually it returns a score, and you decide where to threshold it into “accept/reject.”&lt;/p&gt;

&lt;p&gt;In practice, “detector” covers everything from spectral-feature classifiers to deep nets trained on spoofing corpora.&lt;/p&gt;

&lt;p&gt;The problem is not that detectors never work. The problem is how people deploy them. They treat the output like a binary oracle.&lt;/p&gt;

&lt;p&gt;A detector score is just a score. You still have to decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What &lt;strong&gt;false positive rate (FPR)&lt;/strong&gt; you can afford.&lt;/li&gt;
&lt;li&gt;What happens operationally when you’re wrong.&lt;/li&gt;
&lt;li&gt;How performance moves when the audio goes through codecs, channels, and adversarial transforms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’ve built &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; or shipped any kind of &lt;a href="https://dev.to/blog/evaluate-ai-agents-production"&gt;production AI&lt;/a&gt; system, you already know this pattern. The model output is the easy part. The decision policy is where you bleed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How AI voice detectors work (high-level)
&lt;/h2&gt;

&lt;p&gt;Most modern systems boil down to: learn a representation of speech, then learn what synthetic artifacts look like.&lt;/p&gt;

&lt;p&gt;Common ingredients:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Front-end features&lt;/strong&gt;: log-mel spectrograms, CQCC/LFCC variants, phase features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backbone networks&lt;/strong&gt;: CNNs, Transformers, conformer-ish audio encoders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training signal&lt;/strong&gt;: labels from spoofing datasets plus whatever synthetic generators the vendor had access to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two uncomfortable truths that show up fast once you start benchmarking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detectors learn &lt;strong&gt;shortcut cues&lt;/strong&gt; (codec artifacts, silence patterns, resampling fingerprints) instead of “synthetic-ness.”&lt;/li&gt;
&lt;li&gt;Attackers don’t need to beat your model. They need to beat your &lt;strong&gt;deployment conditions&lt;/strong&gt;. If your call-center pipeline down-samples to 8 kHz but your benchmark is pristine 48 kHz WAV, your numbers are fiction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why I like borrowing habits from my benchmark work. Based on the benchmark data I maintain at &lt;a href="https://www.kunalganglani.com/llm-benchmarks" rel="noopener noreferrer"&gt;kunalganglani.com/llm-benchmarks&lt;/a&gt;, I’ve learned that reproducibility is mostly discipline. Fixed inputs, pinned versions, boring manifests. The same muscle applies here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failure modes / limitations (the stuff vendor pages skip)
&lt;/h2&gt;

&lt;p&gt;If you’re building this harness to pressure-test vendors, start by accepting that you’re going to find ways the detector falls apart. That’s not a “gotcha.” That’s the whole point.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Channel mismatch (mic, room, phone)
&lt;/h3&gt;

&lt;p&gt;A detector that looks incredible on studio-grade clips can collapse the moment you introduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Telephony band-limiting (narrowband &lt;strong&gt;8 kHz&lt;/strong&gt; is still common)&lt;/li&gt;
&lt;li&gt;Room reverb and far-field mics&lt;/li&gt;
&lt;li&gt;Bluetooth codec weirdness&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don’t test those, you are not testing fraud risk. You’re testing a demo.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Generator-family overfitting
&lt;/h3&gt;

&lt;p&gt;Detectors trained heavily on one family of TTS/VC models get “good” at spotting that family’s artifacts. Swap vendors and the ROC curve shifts.&lt;/p&gt;

&lt;p&gt;Your harness has to mix generators on purpose. If you only use a single voice-clone vendor, you’re basically doing a unit test, not an evaluation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Codec fingerprinting
&lt;/h3&gt;

&lt;p&gt;If all your synthetic clips are MP3 and all your real clips are WAV, your detector can “win” by learning MP3. Congrats. You built a codec detector.&lt;/p&gt;

&lt;p&gt;Rule: ensure &lt;strong&gt;codec parity&lt;/strong&gt; across classes, or explicitly report performance per codec.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Score calibration lies
&lt;/h3&gt;

&lt;p&gt;A vendor will show you “99% confidence.” That number is meaningless unless they can show calibration (reliability curves, expected calibration error). A score of 0.9 does not mean 90% probability.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Thresholds picked after looking at the test set
&lt;/h3&gt;

&lt;p&gt;This is the silent killer. Teams tune thresholds on the evaluation set until the chart looks pretty. Then they “lock” it and declare victory.&lt;/p&gt;

&lt;p&gt;Your harness should force the threshold to be chosen on &lt;strong&gt;dev&lt;/strong&gt;, then frozen on &lt;strong&gt;test&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If this sounds like I’m being annoying, good. Running this blog’s multi-agent publishing pipeline taught me that deterministic gates beat “we’ll eyeball it” every time. Same idea here. Decide your rules up front, then let the harness enforce them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test accuracy (metrics that actually matter)
&lt;/h2&gt;

&lt;p&gt;“Accuracy” is a trap metric for detection.&lt;/p&gt;

&lt;p&gt;If deepfake calls are 1 in 10,000, a model that always predicts “real” is &lt;strong&gt;99.99% accurate&lt;/strong&gt; and completely useless.&lt;/p&gt;

&lt;p&gt;Compute and publish the stuff that maps to real operational cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics table (use this in your report)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;Why you should care&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ROC AUC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ranking quality over all thresholds&lt;/td&gt;
&lt;td&gt;Solid for comparing raw separability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;EER&lt;/strong&gt; (Equal Error Rate)&lt;/td&gt;
&lt;td&gt;Where FPR = FNR&lt;/td&gt;
&lt;td&gt;Common in spoofing literature, easy to communicate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Precision / Recall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Performance at a specific threshold&lt;/td&gt;
&lt;td&gt;Lets you reason about review workload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FPR @ fixed TPR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;“How many legit users do I hurt to catch X% of attacks”&lt;/td&gt;
&lt;td&gt;Maps to operational cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Calibration&lt;/strong&gt; (ECE / reliability)&lt;/td&gt;
&lt;td&gt;Whether scores match reality&lt;/td&gt;
&lt;td&gt;Prevents “0.99 confidence” theater&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want a canonical reference ecosystem: ASVspoof (Automatic Speaker Verification Spoofing and Countermeasures) is the long-running community benchmark series. The official hub is &lt;a href="https://www.asvspoof.org/" rel="noopener noreferrer"&gt;ASVspoof&lt;/a&gt;. They publish evaluation plans and results summaries. I’m not saying you must use their data. I’m saying your harness should be compatible with that style of reporting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Thresholding: pick a false-positive budget, not a vibe
&lt;/h3&gt;

&lt;p&gt;This is where detectors go to die in real deployments.&lt;/p&gt;

&lt;p&gt;Pick an FPR target based on your workflow. Example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You process &lt;strong&gt;100,000 calls/day&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You can afford &lt;strong&gt;50&lt;/strong&gt; manual reviews/day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That implies an FPR budget of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;50 / 100,000 = &lt;strong&gt;0.0005&lt;/strong&gt; = &lt;strong&gt;0.05% FPR&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now evaluate detectors at the threshold that hits &lt;strong&gt;0.05% FPR on the dev set&lt;/strong&gt;, and report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recall (TPR) at that threshold&lt;/li&gt;
&lt;li&gt;The expected daily review count (should be ~50)&lt;/li&gt;
&lt;li&gt;The expected miss rate given your estimated attack prevalence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s what “justifying a threshold” looks like. Not maximizing accuracy on a test set and hoping no one asks questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the harness: dataset, splits, transforms, baselines
&lt;/h2&gt;

&lt;p&gt;Here’s my opinion, stated plainly: if you don’t have a manifest and deterministic splits, you don’t have a benchmark. You have a folder.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Dataset design when you don’t want to expose your real voice
&lt;/h3&gt;

&lt;p&gt;You can evaluate detectors without publishing your voice. Do it like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Record a private set of real clips from yourself (or internal volunteers) in &lt;strong&gt;3 conditions&lt;/strong&gt;: quiet room, noisy room, phone mic.&lt;/li&gt;
&lt;li&gt;Generate synthetic clips from those same scripts using &lt;strong&gt;2+ clone vendors/models&lt;/strong&gt; (or multiple settings of one vendor).&lt;/li&gt;
&lt;li&gt;Normalize so both real and synthetic exist under the same transforms (same codec family, same sample rates).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, for publication, release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hashes of the raw files&lt;/li&gt;
&lt;li&gt;Per-file metadata (speaker ID pseudonyms, script ID, generator family, transform chain)&lt;/li&gt;
&lt;li&gt;Aggregate metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the raw audio private.&lt;/p&gt;

&lt;p&gt;If you’re already doing privacy work for language systems, the mental model is similar to &lt;a href="https://dev.to/blog/field-level-redaction-rag"&gt;field-level redaction for RAG pipelines&lt;/a&gt;. You want reproducibility without exposure.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Manifest format (what to store)
&lt;/h3&gt;

&lt;p&gt;Minimum viable manifest fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt; (stable)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;label&lt;/code&gt; (&lt;code&gt;real&lt;/code&gt; / &lt;code&gt;synthetic&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sha256&lt;/code&gt; of the audio bytes&lt;/li&gt;
&lt;li&gt;&lt;code&gt;duration_ms&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sample_rate_hz&lt;/code&gt; (e.g., &lt;strong&gt;16000&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;codec&lt;/code&gt; (wav/opus/aac/mp3)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;speaker&lt;/code&gt; (pseudonym)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;script_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;generator_family&lt;/code&gt; (e.g., “vendorA-vc”, “open-source-tts”) or &lt;code&gt;none&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;transform_chain&lt;/code&gt; (e.g., &lt;code&gt;phone8k-&amp;gt;opus24k-&amp;gt;re_recorded&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you care about supply-chain integrity, this is the same playbook as &lt;a href="https://dev.to/blog/verify-gguf-hashes-supply-chain"&gt;Verify GGUF model hashes supply chain&lt;/a&gt;. Hash everything. Your future self will thank you when you try to reproduce a run six months later.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Deterministic splits (how to prevent leakage)
&lt;/h3&gt;

&lt;p&gt;Leakage is sneaky in audio because you can “accidentally” teach the harness the answer.&lt;/p&gt;

&lt;p&gt;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Split by &lt;strong&gt;speaker + script&lt;/strong&gt; so the same sentence from the same speaker doesn’t show up in both dev and test.&lt;/li&gt;
&lt;li&gt;Keep generator vendors balanced across splits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good starting point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Train (if you’re training anything): &lt;strong&gt;70%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Dev (threshold selection): &lt;strong&gt;15%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Test (frozen): &lt;strong&gt;15%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if you aren’t training detectors, you still need dev vs test. Otherwise you’ll pick a threshold after you’ve already seen the answers.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Transformation matrix (robustness tests)
&lt;/h3&gt;

&lt;p&gt;Your harness should produce a matrix, not a single hero number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clean (reference)&lt;/li&gt;
&lt;li&gt;Phone narrowband (simulate &lt;strong&gt;300–3400 Hz&lt;/strong&gt; bandlimit, resample &lt;strong&gt;8 kHz&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Lossy codecs (Opus/AAC/MP3 at 24–64 kbps)&lt;/li&gt;
&lt;li&gt;Additive noise (+10 dB, +0 dB SNR)&lt;/li&gt;
&lt;li&gt;Re-recording (play through speakers, re-capture with a mic)&lt;/li&gt;
&lt;li&gt;Time-scale / pitch shifts (±2–5%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact values matter less than being explicit and rerunnable. “We tested robustness” is not a method. It’s a sentence.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) A sane baseline to beat
&lt;/h3&gt;

&lt;p&gt;If your fancy detector can’t beat these, something is wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Random guess (AUC ~ &lt;strong&gt;0.5&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Energy/VAD-only heuristic (flags weird silence patterns)&lt;/li&gt;
&lt;li&gt;Simple spectral centroid / roll-off classifier&lt;/li&gt;
&lt;li&gt;“Codec-only” baseline (should fail if you did codec parity right)&lt;/li&gt;
&lt;li&gt;A lightweight logistic regression on log-mel stats&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You don’t need these baselines to be amazing. You need them to catch the classic failure: “my detector is exploiting an artifact in my dataset.”&lt;/p&gt;

&lt;h3&gt;
  
  
  6) Compare multiple detectors fairly
&lt;/h3&gt;

&lt;p&gt;Fair comparison rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same input audio bytes (verified by hash)&lt;/li&gt;
&lt;li&gt;Same resampling policy (e.g., always to &lt;strong&gt;16 kHz mono&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Same scoring convention (higher = more synthetic)&lt;/li&gt;
&lt;li&gt;Same thresholding recipe (chosen on dev, applied to test)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re doing this inside a broader security program, connect it to the rest of your evaluation discipline. My posts on &lt;a href="https://dev.to/blog/ai-security-complete-guide"&gt;AI security&lt;/a&gt; and &lt;a href="https://dev.to/blog/llm-observability-metrics"&gt;AI in production&lt;/a&gt; are the same thesis in different clothes: measure what hurts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish results without doxxing yourself (and still be credible)
&lt;/h2&gt;

&lt;p&gt;This is the part people skip because it’s slightly inconvenient. It’s also the difference between “trust me” and “here’s the evidence.”&lt;/p&gt;

&lt;h3&gt;
  
  
  What to publish (the “public artifacts” bundle)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;manifest.jsonl&lt;/code&gt; with hashes + metadata&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;splits.json&lt;/code&gt; (ids in dev/test)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;detectors.lock&lt;/code&gt; (name + version + config for each detector)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runner&lt;/code&gt; script/container definition&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;results.json&lt;/code&gt; with aggregate metrics per condition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If someone wants to replicate exactly, they can run it against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Their own private audio (same manifest schema)&lt;/li&gt;
&lt;li&gt;Public datasets they’re allowed to redistribute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And compare apples-to-apples.&lt;/p&gt;

&lt;h3&gt;
  
  
  What NOT to publish
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Raw recordings of your real voice&lt;/li&gt;
&lt;li&gt;Clone training audio&lt;/li&gt;
&lt;li&gt;Any mapping from pseudonym speaker IDs to real identity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re worried about people reconstructing identity from embeddings or acoustic fingerprints, good. Treat voice like biometric data.&lt;/p&gt;

&lt;p&gt;Security culture matters here. If your org is still “ship first, think later,” you’ll end up doing this after an incident, not before it. The same regression mindset that prevents &lt;a href="https://dev.to/blog/prompt-injection-regression-testing-ci"&gt;prompt injection&lt;/a&gt; failures applies. You don’t want a one-off test that someone ran once. You want a rerunnable gate that fails loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best practices / tips (so your benchmark doesn’t lie to you)
&lt;/h2&gt;

&lt;p&gt;A few rules I enforce in my own eval work.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Version pin everything.&lt;/strong&gt; Detector model versions, preprocessing libraries, even ffmpeg builds. “Latest” is not a method.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the full pipeline.&lt;/strong&gt; Sample rate in/out, clipping count, duration, RMS. If you can’t explain a regression, your harness is just vibes with charts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rerun on a schedule.&lt;/strong&gt; Monthly is reasonable. Fraud tactics change faster than your procurement cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report confidence intervals.&lt;/strong&gt; If your test set is 200 clips, your metric variance is huge. Grow the dataset before you brag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a holdout generator.&lt;/strong&gt; Don’t evaluate solely on the same vendor you used to design the test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t trust a single metric.&lt;/strong&gt; AUC can look great while your FPR at the operating point is unusable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat detectors as a control, not a silver bullet.&lt;/strong&gt; Pair with liveness, device signals, and step-up auth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you’re building agentic systems too, this whole “deterministic harness + artifacts bundle” pattern generalizes cleanly. It’s the same move as an &lt;a href="https://dev.to/blog/agent-evaluation-harness-replay"&gt;agent evaluation harness&lt;/a&gt; or a &lt;a href="https://dev.to/blog/rag-data-leakage-test-suite"&gt;RAG&lt;/a&gt; leakage test suite. Different domain, same discipline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two authoritative references worth reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The community benchmark lineage at &lt;a href="https://www.asvspoof.org/" rel="noopener noreferrer"&gt;ASVspoof&lt;/a&gt; (evaluation plans and challenge structure).&lt;/li&gt;
&lt;li&gt;The current security taxonomy work at &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/" rel="noopener noreferrer"&gt;OWASP&lt;/a&gt; (GenAI security project pointers). Not voice-specific, but useful for thinking about controls and failure modes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A prediction (and your homework)
&lt;/h2&gt;

&lt;p&gt;Within 12 months, “AI voice detection” will split into two markets: cheap detectors optimized for clean, uploaded audio, and expensive systems tuned for call-center channels with aggressive false-positive budgets. Most vendors will keep advertising the clean-audio number because it photographs better.&lt;/p&gt;

&lt;p&gt;Your homework is simple. Build the harness. Pick an FPR budget. Publish the artifacts bundle. If a vendor won’t engage with that level of transparency, they’re telling you exactly how much you should trust their detector.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/ai-voice-detector-accuracy-test?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-voice-detector-accuracy-test" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>deepfakeaudio</category>
      <category>voicesecurity</category>
      <category>evaluation</category>
      <category>fraudprevention</category>
    </item>
    <item>
      <title>Verify GGUF Model Hashes Supply Chain [2026]: 10 Steps</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Mon, 31 Aug 2026 00:41:53 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/verify-gguf-model-hashes-supply-chain-2026-10-steps-5e0m</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/verify-gguf-model-hashes-supply-chain-2026-10-steps-5e0m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/verify-gguf-hashes-supply-chain" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want to &lt;strong&gt;verify GGUF model hashes supply chain&lt;/strong&gt; style, the goal is simple. Every GGUF that lands on a laptop, workstation, or server should be provably the exact bytes you approved. No “downloaded it from a mirror and it worked” vibes.&lt;/p&gt;

&lt;p&gt;Here’s the part that trips teams up. &lt;strong&gt;You need a stable source of truth for the expected hash.&lt;/strong&gt; If the publisher doesn’t provide hashes (or better, a signed manifest), you’re not verifying anything. You’re just doing math on a file you already decided to trust.&lt;/p&gt;

&lt;p&gt;This is the workflow I wish more teams ran before they started shipping local model tooling into real environments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Inline image here: an overview diagram of the workflow from upstream → verify → scan → internal publish → updates.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is GGUF supply chain verification?
&lt;/h2&gt;

&lt;p&gt;GGUF supply chain verification is the practice of validating a downloaded GGUF model file’s &lt;strong&gt;integrity and provenance&lt;/strong&gt; by checking it against trusted hashes and signatures, and then distributing it through controlled channels so updates can’t be silently swapped.&lt;/p&gt;

&lt;p&gt;A GGUF is usually “just data,” not an executable. That’s exactly why people get sloppy. The real risks are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Artifact swapping&lt;/strong&gt;: you think you got &lt;code&gt;model-q4.gguf&lt;/code&gt;, you actually got a different blob.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malicious sidecars&lt;/strong&gt;: scripts, archives, installers, “helper” binaries, tokenizers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boring runtime bugs&lt;/strong&gt;: parsers and loaders are not sacred. They break.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already have a sane workflow for containers, binaries, or packages, this is the same muscle. You’re just applying it to model artifacts.&lt;/p&gt;

&lt;p&gt;Two references help frame this without sending you into threat-model land for a week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;SLSA&lt;/strong&gt; maturity model is a good checklist for “how hard are we making it to tamper with artifacts.” The SLSA site defines it as “a security framework, a checklist of standards and controls to prevent tampering, improve integrity, and secure packages and infrastructure.” (&lt;a href="https://slsa.dev/" rel="noopener noreferrer"&gt;SLSA working group&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MITRE ATLAS&lt;/strong&gt; is a catalog of adversary techniques against ML systems. Even if you’re “only running local,” the artifact is still a target. (&lt;a href="https://atlas.mitre.org/" rel="noopener noreferrer"&gt;MITRE&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m not turning this into academic threat modeling. This is a workflow you can actually run as a team next week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify GGUF model hashes supply chain: 10-step workflow
&lt;/h2&gt;

&lt;p&gt;This is the end-to-end flow I recommend. It’s strict on purpose. If a step feels annoying, good. That friction is what prevents the “we grabbed it from a random Discord mirror at 2am” incident.&lt;/p&gt;

&lt;p&gt;[YOUTUBE:gBJ169_il6k|Securing GitOps Supply Chain with Sigstore and Kyverno - Roberto Carratala &amp;amp; Faz Sadeghi, Red Hat]&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin an immutable model identity&lt;/strong&gt;: capture &lt;code&gt;{publisher}/{model}/{version}/{quant}/{file}&lt;/code&gt;. Never approve “latest”.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get expected hashes from a real source of truth&lt;/strong&gt;: release notes, &lt;code&gt;SHA256SUMS&lt;/code&gt;, or your own internal manifest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download via HTTPS only&lt;/strong&gt; and save the exact URL and timestamp in a small receipt file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute SHA256 locally&lt;/strong&gt; on the GGUF blob.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare SHA256&lt;/strong&gt; to the expected hash. Exact match or fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify publisher identity&lt;/strong&gt;: prefer a signed manifest workflow over “trust me bro hashes.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan the GGUF and its sidecars&lt;/strong&gt; (archives, tokenizers, scripts) with baseline malware tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish into an internal registry&lt;/strong&gt; (object storage or static server) as the only allowed download source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship updates via versioned manifests&lt;/strong&gt; plus allowlists/denylists, staged rollouts, and rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuously re-verify&lt;/strong&gt; at fetch time (CI gate) and at run time (startup check), not just once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A concrete number to keep you honest. The GGUFs teams pass around are routinely &lt;strong&gt;multiple GB&lt;/strong&gt; in size. Git LFS even calls out “as large as a couple GB.” (&lt;a href="https://git-lfs.com/" rel="noopener noreferrer"&gt;Git LFS project&lt;/a&gt;) That size is why people reach for mirrors and caches. And that’s where swapping attacks get easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute and verify SHA256 (Windows/macOS/Linux)
&lt;/h2&gt;

&lt;p&gt;This is the copy-paste part.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linux
&lt;/h3&gt;

&lt;p&gt;Compute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sha256sum model.gguf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify against a manifest line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;echo "&amp;lt;EXPECTED_SHA256&amp;gt;  model.gguf" | sha256sum --check -&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  macOS
&lt;/h3&gt;

&lt;p&gt;Compute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;shasum -a 256 model.gguf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;echo "&amp;lt;EXPECTED_SHA256&amp;gt;  model.gguf" | shasum -a 256 --check -&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Windows (PowerShell)
&lt;/h3&gt;

&lt;p&gt;Compute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Get-FileHash .\model.gguf -Algorithm SHA256&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Windows built-in &lt;code&gt;certutil&lt;/code&gt; (handy on locked-down machines):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;certutil -hashfile model.gguf SHA256&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I actually enforce on teams is simple. The command output goes into a small &lt;code&gt;receipt.json&lt;/code&gt; file next to the artifact in our internal store. If you can’t tell me &lt;em&gt;which bytes&lt;/em&gt; we ran, we didn’t run it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Inline image here: terminal output showing a SHA256 verification for a GGUF before mirroring internally. Alt: “Terminal output showing SHA256 verification for a downloaded GGUF model before internal mirroring.”)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Signed manifests and publisher identity (Cosign/GPG + “official hashes”)
&lt;/h2&gt;

&lt;p&gt;Hashes are table stakes. The question you’re dodging when you stop at hashes is: &lt;strong&gt;who wrote the hash?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the upstream publisher provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;SHA256SUMS&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;plus a signature over that file (GPG or Sigstore)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then you have something you can reason about. You’re no longer trusting a random webpage. You’re verifying an identity.&lt;/p&gt;

&lt;p&gt;If they don’t, you have two real options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Risk-accept it&lt;/strong&gt;: treat the artifact as unverified third-party input. Sandbox it harder. Don’t mirror it broadly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promote it internally&lt;/strong&gt;: your team computes hashes once, then treats your internal manifest as the source of truth going forward.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A team-ready pattern: sign the manifest, not every blob
&lt;/h3&gt;

&lt;p&gt;Signing each multi-GB GGUF is doable, but it’s operationally annoying and people will “temporarily” skip it. The pattern that tends to stick:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generate &lt;code&gt;SHA256SUMS&lt;/code&gt; for the exact GGUF files you’re approving&lt;/li&gt;
&lt;li&gt;sign &lt;code&gt;SHA256SUMS&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;require verification of the signature plus a hash match in CI before a model is “allowed”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sigstore’s Cosign is built for signing and verifying artifacts. The official docs describe Cosign as supporting signing and verification, with transparency log support. (&lt;a href="https://docs.sigstore.dev/" rel="noopener noreferrer"&gt;Sigstore project&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;I’m keeping the tooling discussion light because teams vary (GitHub release signing, GPG, keyless OIDC). The invariant doesn’t change. &lt;strong&gt;Hashes without identity are just checksums.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Git LFS gotcha: don’t confuse the pointer with the blob
&lt;/h3&gt;

&lt;p&gt;A common failure mode is this: “the commit is trusted, so the model is trusted.” Nope.&lt;/p&gt;

&lt;p&gt;Git LFS explicitly says it “replaces large files … with text pointers inside Git, while storing the file contents on a remote server.” (&lt;a href="https://git-lfs.com/" rel="noopener noreferrer"&gt;Git LFS project&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;So your integrity check must apply to the &lt;strong&gt;downloaded GGUF object&lt;/strong&gt;, not the Git commit that contains a pointer file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mirrors, internal registries, and secure update channels
&lt;/h2&gt;

&lt;p&gt;Mirrors aren’t evil. Blind trust in mirrors is.&lt;/p&gt;

&lt;p&gt;If you mirror models inside a company, you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;one canonical internal origin&lt;/strong&gt; (S3/R2/GCS, Artifactory-like store, even a static server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;immutable paths&lt;/strong&gt; like &lt;code&gt;/models/&amp;lt;name&amp;gt;/&amp;lt;version&amp;gt;/&amp;lt;file&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a versioned manifest&lt;/strong&gt; that lists allowed models and exact hashes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;access control and audit logs&lt;/strong&gt; around who can publish or promote new artifacts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hugging Face is where many teams source upstream artifacts. Their Hub security docs cover token-based access and org/repo permissioning that you can use to separate “upstream fetching” from “internal distribution.” (&lt;a href="https://huggingface.co/docs/hub/security" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  Mirror rules that prevent “model swapping”
&lt;/h3&gt;

&lt;p&gt;These rules prevent the dumb incidents. The ones you only have to live through once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never accept &lt;code&gt;latest&lt;/code&gt;. Require an explicit version string.&lt;/li&gt;
&lt;li&gt;Pin by &lt;strong&gt;hash&lt;/strong&gt;, not just by filename.&lt;/li&gt;
&lt;li&gt;Compare hashes across &lt;strong&gt;two independent sources&lt;/strong&gt; when possible.&lt;/li&gt;
&lt;li&gt;Treat your internal store as append-only for released versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Internal model registry: the boring design that works
&lt;/h3&gt;

&lt;p&gt;A minimal internal registry can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an object storage bucket&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;models/&lt;/code&gt; prefix&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;manifests/approved-models.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a CI job that refuses to publish if &lt;code&gt;sha256&lt;/code&gt; doesn’t match&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is one of those things where the boring answer is actually the right one.&lt;/p&gt;

&lt;p&gt;I learned this the hard way building this site’s multi-agent publishing pipeline. One identity mistake. A slug rewrite on live URLs. Burned &lt;strong&gt;907K impressions&lt;/strong&gt; of link equity in a single incident. Artifact identity is a one-way door. Treat model artifacts the same way.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Inline image here: internal registry layout, manifest + hashes + versioned folders. Alt: “Internal model registry layout with versioned folders and a signed SHA256 manifest used for controlled GGUF distribution.”)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model + scanning: what scanning can (and can’t) do
&lt;/h2&gt;

&lt;p&gt;When people hear “malicious GGUF,” they imagine the weights are secretly executable. That’s not the main failure mode.&lt;/p&gt;

&lt;p&gt;The stuff that bites teams in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Artifact swap&lt;/strong&gt;: same filename, different bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache poisoning or mirror tampering&lt;/strong&gt;: you fetch from a “fast” source that isn’t authoritative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malicious sidecars&lt;/strong&gt;: tokenizers, config files, scripts, installers, “run this to optimize.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loader vulnerabilities&lt;/strong&gt;: a crafted file triggers a bug in the runtime parsing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Baseline scanning with ClamAV
&lt;/h3&gt;

&lt;p&gt;Antivirus scanning won’t detect “trojaned weights” in an ML sense. It can still catch regular malware when it’s packaged alongside weights, especially in archives.&lt;/p&gt;

&lt;p&gt;ClamAV is a pragmatic baseline because it’s widely available and automatable. Their docs describe it as an open source anti-virus toolkit with an on-demand command line scanner, and they explicitly list archive scanning support (Zip, Tar, 7Zip, etc.). (&lt;a href="https://docs.clamav.net/" rel="noopener noreferrer"&gt;Cisco Talos (ClamAV)&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Practical commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scan a single file: &lt;code&gt;clamscan --infected --no-summary model.gguf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scan a directory of sidecars: &lt;code&gt;clamscan -r --infected --no-summary ./model_bundle/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make this a CI gate on the internal publish step. It’s cheap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why MITRE ATLAS belongs in this post
&lt;/h3&gt;

&lt;p&gt;If your org is doing anything beyond hobby use, you need a shared vocabulary for attacks on ML systems. MITRE ATLAS exists for that. It catalogs adversary techniques and gives security teams a reference point they can actually talk to each other with. (&lt;a href="https://atlas.mitre.org/" rel="noopener noreferrer"&gt;MITRE&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Scanning helps a bit. Integrity and provenance do the heavy lifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Air-gapped workflow (USB, sneaker-net, and still not getting owned)
&lt;/h2&gt;

&lt;p&gt;Air-gapped doesn’t mean safe. It means slower.&lt;/p&gt;

&lt;p&gt;If you move GGUFs via USB or offline media:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put the GGUF plus &lt;code&gt;SHA256SUMS&lt;/code&gt; plus signature file in a single folder.&lt;/li&gt;
&lt;li&gt;Compute SHA256 on the sending machine and the receiving machine.&lt;/li&gt;
&lt;li&gt;Store the expected hash in a separate channel (printed, ticket, or signed email).&lt;/li&gt;
&lt;li&gt;Treat the USB as hostile. Scan it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want one practical trick here, it’s this. Use an internal manifest repo as the source of truth, and treat the offline transfer as just transport.&lt;/p&gt;

&lt;p&gt;Based on the local model benchmarks I maintain at &lt;a href="https://www.kunalganglani.com/llm-benchmarks" rel="noopener noreferrer"&gt;kunalganglani.com/llm-benchmarks&lt;/a&gt;, teams are now routinely testing multiple quantizations per model (Q4/Q5/Q8 variants). That turns “download a model” into “manage a fleet of artifacts.” The moment you have 6–12 GGUFs floating around, you need a real update channel.&lt;/p&gt;

&lt;p&gt;My prediction is simple. In the next wave of local AI adoption, the teams that win won’t be the ones with the fanciest &lt;a href="https://dev.to/pillars/llm-hardware-local-ai"&gt;local LLM&lt;/a&gt; stack. They’ll be the ones who can answer a basic question in under 60 seconds.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which exact bytes are we running, and who vouched for them?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/verify-gguf-hashes-supply-chain?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=verify-gguf-hashes-supply-chain" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supplychain</category>
      <category>localllm</category>
      <category>gguf</category>
      <category>llmsecurity</category>
    </item>
    <item>
      <title>How to Use an SSH Config Manager on macOS [2026] (Secure Jump Hosts)</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sun, 30 Aug 2026 19:35:03 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-use-an-ssh-config-manager-on-macos-2026-secure-jump-hosts-1kdg</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-use-an-ssh-config-manager-on-macos-2026-secure-jump-hosts-1kdg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/ssh-config-manager-macos" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want a sane, secure “SSH config manager macOS” workflow in 2026, you don’t need a new app. You need a hardened &lt;code&gt;~/.ssh/config&lt;/code&gt; that makes the secure path the easy path.&lt;/p&gt;

&lt;p&gt;Most people who go shopping for a GUI are really trying to avoid two boring chores: key hygiene and a readable config. If you’re still using one shared key for everything, or you casually &lt;code&gt;ssh -A&lt;/code&gt; your agent into random boxes, no macOS SSH manager is going to save you.&lt;/p&gt;

&lt;p&gt;This post gives you a copy/paste &lt;code&gt;~/.ssh/config&lt;/code&gt; template built for jump hosts (bastions), clean tunnels, and less yak-shaving. Then I’ll show where macOS GUI “SSH config managers” actually fit without turning your laptop into a credential dumpster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an SSH config manager on macOS?
&lt;/h2&gt;

&lt;p&gt;An SSH config manager on macOS is any workflow or tool that helps you create, organize, and use SSH connection profiles. That can be plain OpenSSH via &lt;code&gt;~/.ssh/config&lt;/code&gt;, or a GUI app that stores hosts, jump chains, identities, and tunnels and then launches SSH sessions for you.&lt;/p&gt;

&lt;p&gt;Here’s my bias, upfront. &lt;strong&gt;OpenSSH is already the best config manager.&lt;/strong&gt; It’s audited. It’s everywhere. It’s scriptable. And every other “manager” is basically a UI that eventually runs &lt;code&gt;ssh&lt;/code&gt; anyway.&lt;/p&gt;

&lt;p&gt;So when someone asks me for a “macOS SSH config manager,” I translate it as: “How do I stop retyping hostnames, stop breaking tunnels, and stop doing sketchy stuff with keys?” Cool. Let’s solve that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the safest way to use a jump host (bastion) with OpenSSH on macOS?
&lt;/h2&gt;

&lt;p&gt;The safest default is boring. That’s a compliment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;ProxyJump&lt;/strong&gt; (&lt;code&gt;-J&lt;/code&gt;) through a hardened bastion.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;per-host identities&lt;/strong&gt; so compromise of one key doesn’t become “your whole estate is mine.”&lt;/li&gt;
&lt;li&gt;Disable &lt;strong&gt;agent forwarding&lt;/strong&gt; by default.&lt;/li&gt;
&lt;li&gt;Lock down &lt;strong&gt;host key checking&lt;/strong&gt; so you don’t train yourself to ignore MITM warnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, your chain looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Laptop → &lt;code&gt;bastion&lt;/code&gt; (the only host exposed to the internet)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bastion&lt;/code&gt; → private target (&lt;code&gt;db-01&lt;/code&gt;, &lt;code&gt;k8s-01&lt;/code&gt;, whatever)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenSSH supports this natively via &lt;code&gt;ProxyJump&lt;/code&gt; in config and &lt;code&gt;-J&lt;/code&gt; on the command line (&lt;a href="https://man.openbsd.org/ssh" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Two numbers I use to keep this from turning into a Rube Goldberg machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 jump host&lt;/strong&gt; is enough for most small teams. If you’re stacking &lt;strong&gt;2+ hops&lt;/strong&gt; regularly, you’re usually compensating for missing network segmentation or missing identity tooling.&lt;/li&gt;
&lt;li&gt;Multiplexing persistence at &lt;strong&gt;10 minutes&lt;/strong&gt; (&lt;code&gt;ControlPersist 10m&lt;/code&gt;). Long enough that repeated connects don’t feel like punishment. Short enough that you’re not leaving control sockets hanging around all afternoon.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hardened ~/.ssh/config template for jump hosts (copy/paste)
&lt;/h2&gt;

&lt;p&gt;Most “macos ssh config manager” posts wave their hands here and tell you to “use best practices.” No. You want something you can paste, run, and then tweak.&lt;/p&gt;

&lt;p&gt;Set up a simple layout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.ssh/config&lt;/code&gt; (tiny, just includes)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/config.d/base.conf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/config.d/jump.conf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/config.d/tunnels.conf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1) ~/.ssh/config
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Include ~/.ssh/config.d/*.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Base defaults (config.d/base.conf)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host *
  ServerAliveInterval 30
  ServerAliveCountMax 3

  HashKnownHosts yes
  StrictHostKeyChecking ask

  AddKeysToAgent yes

  IdentitiesOnly yes

  ControlMaster auto
  ControlPersist 10m
  ControlPath ~/.ssh/cm/%C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few quick notes that actually matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ServerAliveInterval 30&lt;/code&gt; plus &lt;code&gt;ServerAliveCountMax 3&lt;/code&gt; means you’ll notice dead tunnels in about &lt;strong&gt;90 seconds&lt;/strong&gt; instead of “why is my command stuck” fifteen minutes later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; is the difference between “SSH is calm and predictable” and “Received disconnect: Too many authentication failures” when your agent is full of old keys.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;%C&lt;/code&gt; gives you a unique control socket name per host/port/user, so you don’t get weird collisions when you bounce between environments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; create the control socket directory with tight permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mkdir -p ~/.ssh/cm&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chmod 700 ~/.ssh/cm&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you leave that directory readable to other users on the machine, you’ve turned multiplexing into a local “see what I can poke” situation.&lt;/p&gt;

&lt;p&gt;OpenSSH multiplexing lives under &lt;code&gt;ControlMaster&lt;/code&gt;, &lt;code&gt;ControlPersist&lt;/code&gt;, and &lt;code&gt;ControlPath&lt;/code&gt; in the upstream config reference (&lt;a href="https://man.openbsd.org/ssh_config" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Jump host + private targets (config.d/jump.conf)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host bastion-prod
  HostName bastion.prod.example.com
  User ec2-user
  IdentityFile ~/.ssh/keys/prod_bastion_ed25519

Host *.prod.internal
  User ubuntu
  ProxyJump bastion-prod
  IdentityFile ~/.ssh/keys/prod_workload_ed25519

  ForwardAgent no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the pattern I want burned into your muscle memory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bastion has its own key.&lt;/li&gt;
&lt;li&gt;Private workloads have a different key.&lt;/li&gt;
&lt;li&gt;Agent forwarding stays off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ProxyJump is first-class in OpenSSH (&lt;code&gt;-J&lt;/code&gt; and &lt;code&gt;ProxyJump&lt;/code&gt;). Use it by default instead of dragging &lt;code&gt;ProxyCommand&lt;/code&gt; around forever (&lt;a href="https://man.openbsd.org/ssh" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I set up ProxyJump vs ProxyCommand, and when should I prefer ProxyJump?
&lt;/h2&gt;

&lt;p&gt;Prefer &lt;code&gt;ProxyJump&lt;/code&gt; basically always.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ProxyJump&lt;/code&gt; is &lt;strong&gt;built-in&lt;/strong&gt;, readable, and supports &lt;strong&gt;multiple hops&lt;/strong&gt; without custom shell quoting.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ProxyCommand&lt;/code&gt; is the older escape hatch. It’s still useful when you truly need something odd like traversing a non-SSH transport or wrapping a custom proxy. It’s also easier to screw up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your current “ssh jump host proxyjump config” looks like a 200-character &lt;code&gt;ProxyCommand&lt;/code&gt; line with nested quotes, you’ve built a footgun. Replace it with &lt;code&gt;ProxyJump&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A concrete smell test: if your jump chain is &lt;strong&gt;1 hop&lt;/strong&gt;, you should be able to express it as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;config: &lt;code&gt;ProxyJump bastion-prod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CLI: &lt;code&gt;ssh -J bastion-prod db-01.prod.internal&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can’t, you’re overcomplicating it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I configure ControlMaster/ControlPersist safely, and how do I avoid stale control sockets?
&lt;/h2&gt;

&lt;p&gt;Multiplexing is the highest-ROI quality-of-life feature in SSH. It’s also where people get nervous because “sockets” and “state.” Fair.&lt;/p&gt;

&lt;p&gt;The rules that keep it sane:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep &lt;code&gt;ControlPersist&lt;/code&gt; bounded. I like &lt;strong&gt;10m&lt;/strong&gt;. If you want to be more aggressive, try &lt;strong&gt;2m&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;ControlPath&lt;/code&gt; in a private directory (&lt;code&gt;chmod 700&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Don’t multiplex across identities. If you reuse the same &lt;code&gt;Host&lt;/code&gt; alias but swap &lt;code&gt;User&lt;/code&gt;/&lt;code&gt;IdentityFile&lt;/code&gt;, you’ll get confusing behavior and you’ll blame SSH for your own config.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stale control sockets happen when you suspend the laptop, kill Wi‑Fi, or the bastion drops idle TCP. Two ways out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One-off: &lt;code&gt;ssh -O exit bastion-prod&lt;/code&gt; (asks the master connection to shut down)&lt;/li&gt;
&lt;li&gt;Nuclear: delete the socket file in &lt;code&gt;~/.ssh/cm/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;ssh(1)&lt;/code&gt; client supports control commands via &lt;code&gt;-O&lt;/code&gt; and control paths via &lt;code&gt;-S&lt;/code&gt; (&lt;a href="https://man.openbsd.org/ssh" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;When should you &lt;strong&gt;not&lt;/strong&gt; multiplex?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On shared machines.&lt;/li&gt;
&lt;li&gt;When you’re doing high-sensitivity access and you want every session to require a fresh auth boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I use per-host identities (IdentityFile + IdentitiesOnly) to prevent failures and key sprawl?
&lt;/h2&gt;

&lt;p&gt;Per-host identities aren’t about being tidy. They’re about blast radius.&lt;/p&gt;

&lt;p&gt;The most common failure mode goes like this: somebody has &lt;strong&gt;10–20 keys&lt;/strong&gt; loaded into their agent, SSH tries them all, the server has a low attempt limit, and the connection gets kicked. Then the “fix” becomes randomly deleting keys until it works.&lt;/p&gt;

&lt;p&gt;That’s not engineering. That’s vibes.&lt;/p&gt;

&lt;p&gt;The deterministic fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put a specific &lt;code&gt;IdentityFile&lt;/code&gt; on each host stanza.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; globally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/keys/prod_bastion_ed25519&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/keys/prod_workload_ed25519&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/keys/staging_workload_ed25519&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three keys is not overkill. It’s the point.&lt;/p&gt;

&lt;p&gt;On macOS, I also like &lt;code&gt;AddKeysToAgent yes&lt;/code&gt; so new keys get loaded on first use instead of you babysitting &lt;code&gt;ssh-add&lt;/code&gt; all day (&lt;a href="https://man.openbsd.org/ssh_config" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is agent forwarding dangerous, and what should I do instead for multi-hop access?
&lt;/h2&gt;

&lt;p&gt;Agent forwarding is dangerous because it extends your authentication capability to the remote machine. If that machine is compromised, your forwarded agent can be abused to authenticate to other hosts.&lt;/p&gt;

&lt;p&gt;OpenSSH doesn’t sugarcoat it. &lt;code&gt;ForwardAgent&lt;/code&gt; “should be enabled with care” (&lt;a href="https://man.openbsd.org/ssh_config#ForwardAgent" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My stance: &lt;strong&gt;ForwardAgent should be &lt;code&gt;no&lt;/code&gt; by default.&lt;/strong&gt; If a workflow requires &lt;code&gt;ssh -A&lt;/code&gt;, treat it like &lt;code&gt;sudo&lt;/code&gt;. Explicit, audited, and rare.&lt;/p&gt;

&lt;p&gt;What to do instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;ProxyJump&lt;/code&gt; for multi-hop access.&lt;/li&gt;
&lt;li&gt;Use per-host keys.&lt;/li&gt;
&lt;li&gt;If you need a stronger control plane, stop duct-taping SSH and use a managed access system (more on Teleport below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I manage SSH tunnels cleanly with failure detection and keep-alives?
&lt;/h2&gt;

&lt;p&gt;Tunnels are where ad-hoc SSH workflows become actively dangerous.&lt;/p&gt;

&lt;p&gt;The classic failure: you think your tunnel is up, you point a tool at &lt;code&gt;localhost:5432&lt;/code&gt;, and you’re actually hitting your local machine (or some other service) because the forward never established. Congrats, you’re debugging the wrong system.&lt;/p&gt;

&lt;p&gt;Two config patterns prevent this:&lt;/p&gt;

&lt;p&gt;1) Make “tunnel-only” stanzas with &lt;code&gt;RequestTTY no&lt;/code&gt; and &lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt;.&lt;br&gt;
2) Keep them alive with &lt;code&gt;ServerAliveInterval&lt;/code&gt; (already in the base defaults).&lt;/p&gt;

&lt;p&gt;Example &lt;code&gt;config.d/tunnels.conf&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;Host tunnel-prod-db
  HostName db-01.prod.internal
  ProxyJump bastion-prod
  User ubuntu
  IdentityFile ~/.ssh/keys/prod_workload_ed25519

  RequestTTY no
  ExitOnForwardFailure yes

  LocalForward 15432 127.0.0.1:5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ssh -N tunnel-prod-db&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…and if port &lt;code&gt;15432&lt;/code&gt; can’t bind, the command fails fast. That’s the whole point of &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; (&lt;a href="https://man.openbsd.org/ssh_config" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If you want SOCKS for ad-hoc debugging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host socks-prod
  HostName bastion.prod.example.com
  User ec2-user
  IdentityFile ~/.ssh/keys/prod_bastion_ed25519

  RequestTTY no
  ExitOnForwardFailure yes
  DynamicForward 1080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s your “ssh tunnel manager mac” without installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should I handle known_hosts and host key rotation securely?
&lt;/h2&gt;

&lt;p&gt;Everyone ignores this until a contractor trains the whole team to type “yes” on muscle memory. Then you get a real MITM warning and nobody believes it.&lt;/p&gt;

&lt;p&gt;My defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;StrictHostKeyChecking ask&lt;/code&gt; (not &lt;code&gt;no&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HashKnownHosts yes&lt;/code&gt; so your &lt;code&gt;known_hosts&lt;/code&gt; file is less useful if someone steals your laptop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams, I like separating &lt;code&gt;known_hosts&lt;/code&gt; by environment so you don’t end up with “prod and dev have the same hostname and now everything is broken.” Use per-stanza &lt;code&gt;UserKnownHostsFile&lt;/code&gt; if you need to.&lt;/p&gt;

&lt;p&gt;Host key rotation is real in 2026. If your servers support it, &lt;code&gt;UpdateHostKeys&lt;/code&gt; can help clients learn additional keys (&lt;a href="https://man.openbsd.org/ssh_config" rel="noopener noreferrer"&gt;OpenBSD Project&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The directive matters less than the policy. Make it explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who approves host key changes?&lt;/li&gt;
&lt;li&gt;Where do you announce rotations?&lt;/li&gt;
&lt;li&gt;What’s the escalation path when someone sees a mismatch?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  SSH config manager macOS: GUI tools vs plain OpenSSH config
&lt;/h2&gt;

&lt;p&gt;GUI managers are tempting for the same reason IDEs are. They make the common path easy.&lt;/p&gt;

&lt;p&gt;The downside is also the same. They introduce a second truth.&lt;/p&gt;

&lt;p&gt;Here’s the pragmatic comparison.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Where it bites you&lt;/th&gt;
&lt;th&gt;Security posture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Engineers, CI, reproducible setups&lt;/td&gt;
&lt;td&gt;Initial learning curve&lt;/td&gt;
&lt;td&gt;Strong. You control keys, host checks, and defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SecureCRT&lt;/td&gt;
&lt;td&gt;Heavy terminal users who want session management + scripting&lt;/td&gt;
&lt;td&gt;Paid, another place to store connection metadata&lt;/td&gt;
&lt;td&gt;Good if you keep keys in files/agent and don’t duplicate secrets. Product details: &lt;a href="https://www.vandyke.com/products/securecrt/" rel="noopener noreferrer"&gt;VanDyke Software&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Royal TSX&lt;/td&gt;
&lt;td&gt;Teams that want shared connection lists without sharing creds&lt;/td&gt;
&lt;td&gt;Easy to over-centralize connection docs&lt;/td&gt;
&lt;td&gt;Good if you use its credential separation model. Feature overview: &lt;a href="https://www.royalapps.com/ts/mac/features" rel="noopener noreferrer"&gt;Royal Apps&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I’m not anti-GUI. I’m anti “GUI as a replacement for fundamentals.”&lt;/p&gt;

&lt;p&gt;If you adopt a GUI, demand two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It must &lt;strong&gt;import/export&lt;/strong&gt; cleanly to &lt;code&gt;~/.ssh/config&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It must not push you toward agent forwarding or shared keys just to make setup “simple.”&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How can I record SSH sessions for auditing on jump hosts?
&lt;/h2&gt;

&lt;p&gt;If you’re serious about production access, authentication logs aren’t enough. “User X logged in” tells you nothing about what they did after.&lt;/p&gt;

&lt;p&gt;Two practical options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Host-level session recording&lt;/strong&gt; with &lt;code&gt;tlog&lt;/code&gt; on Linux jump hosts. It records terminal I/O so you can replay sessions later (&lt;a href="https://github.com/Scribery/tlog" rel="noopener noreferrer"&gt;tlog maintainers&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed access systems&lt;/strong&gt; that treat auditing as a first-class feature. Teleport is the obvious example, with an audit log and session recording as part of its SSH proxy model (Teleport docs move around a lot, but the product’s stance is consistent).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple operational rule: if you have compliance requirements, or you have more than &lt;strong&gt;5–10 engineers&lt;/strong&gt; doing prod access, the time you’ll waste arguing about “who ran what” will quickly exceed the cost of doing session recording properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do GUI SSH managers interact with the OpenSSH config file, and what’s a safe hybrid workflow?
&lt;/h2&gt;

&lt;p&gt;A safe hybrid workflow looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.ssh/config&lt;/code&gt; is the source of truth.&lt;/li&gt;
&lt;li&gt;The GUI reads from it (or you keep them aligned manually, but then be honest that you’re doing double entry).&lt;/li&gt;
&lt;li&gt;Keys live as files with correct permissions, or in the OS keychain. Not copied into random app-specific vaults unless you’ve threat-modeled that.&lt;/li&gt;
&lt;li&gt;Tunnels are defined as dedicated host stanzas (&lt;code&gt;tunnel-prod-db&lt;/code&gt;, &lt;code&gt;socks-prod&lt;/code&gt;) so you can run them from Terminal, the GUI, or scripts.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;This is one of those things where the boring answer is actually the right one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your team can’t reproduce a connection with &lt;code&gt;ssh -F ~/.ssh/config ...&lt;/code&gt;, you don’t have a workflow. You have a collection of personal snowflakes.&lt;/p&gt;

&lt;p&gt;What I’d do next if you want to roll this out: rotate a single environment key and see how many people break. If the answer is “most of them,” that’s not a reason to buy a shinier SSH config manager for macOS. It’s your signal to standardize the template, fix the hygiene, and make secure defaults non-optional.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/ssh-config-manager-macos?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ssh-config-manager-macos" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>macos</category>
      <category>developertools</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Secure Docker Rootless Mode in Production [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:26:16 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-secure-docker-rootless-mode-in-production-2026-8l6</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/how-to-secure-docker-rootless-mode-in-production-2026-8l6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/docker-rootless-mode-security" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  How to Secure Docker Rootless Mode in Production [2026]
&lt;/h1&gt;

&lt;p&gt;I keep seeing the same pattern. A team flips on &lt;strong&gt;Docker rootless mode&lt;/strong&gt;, feels safer, and moves on. Then six months later someone adds a privileged container “just for debugging,” the socket ends up writable by half the org, and rootless becomes a comforting story instead of a control.&lt;/p&gt;

&lt;p&gt;The keyword people search for is &lt;em&gt;docker rootless mode security&lt;/em&gt;. Here’s the uncomfortable truth: rootless is a good first move, not hardening. Hardening is what you do &lt;strong&gt;after&lt;/strong&gt; the first move.&lt;/p&gt;

&lt;p&gt;By the end of this guide you’ll have &lt;strong&gt;Docker rootless mode&lt;/strong&gt; running under a &lt;strong&gt;systemd user service&lt;/strong&gt;, plus a production-ready baseline: &lt;code&gt;userns-remap&lt;/code&gt; decisioning, seccomp + AppArmor, safer container flags, and a short verification playbook you can paste into an audit note. Budget &lt;strong&gt;60–90 minutes&lt;/strong&gt; if you’re starting from a fresh host.&lt;/p&gt;

&lt;p&gt;Docker’s docs have been updated through &lt;strong&gt;2026&lt;/strong&gt; (rootless: &lt;strong&gt;2026-06-07&lt;/strong&gt;, userns-remap: &lt;strong&gt;2026-04-14&lt;/strong&gt;, seccomp: &lt;strong&gt;2026-08-21&lt;/strong&gt;). Rootless is mature enough to be a default. It’s not a magic forcefield.&lt;/p&gt;

&lt;p&gt;If you want the broader “defensible controls” mindset, I’ve been building deterministic gates for this site’s 7-agent publishing pipeline. One lesson keeps repeating: &lt;strong&gt;a single control rarely survives contact with reality&lt;/strong&gt;. Defense-in-depth does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Docker rootless mode and why is it considered more secure?
&lt;/h2&gt;

&lt;p&gt;Docker rootless mode is a Docker Engine configuration where &lt;strong&gt;both the Docker daemon and containers run as a non-root user&lt;/strong&gt;, reducing the impact of a daemon compromise because the daemon no longer has root privileges on the host. Per the official &lt;a href="https://docs.docker.com/engine/security/rootless/" rel="noopener noreferrer"&gt;Docker documentation&lt;/a&gt;, the core win is shrinking the daemon’s privilege boundary.&lt;/p&gt;

&lt;p&gt;Why it’s considered “more secure” is pretty specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The classic rootful Docker daemon is a &lt;strong&gt;high-value target&lt;/strong&gt;. If an attacker gets code execution in the daemon, they’re often one step from host root.&lt;/li&gt;
&lt;li&gt;Rootless makes “daemon RCE == host root” a much harder chain. The attacker lands as an unprivileged user.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s it. That’s the win.&lt;/p&gt;

&lt;p&gt;Rootless does &lt;strong&gt;not&lt;/strong&gt; remove the kernel attack surface. It does &lt;strong&gt;not&lt;/strong&gt; make your containerized app immune to RCE. It does &lt;strong&gt;not&lt;/strong&gt; fix secrets hygiene or network egress. If you’re looking for a single switch that makes audits go away, you’re going to have a bad time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (and where the boundaries really are)
&lt;/h2&gt;

&lt;p&gt;The clean mental model is to separate three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Docker daemon&lt;/strong&gt; (builds, pulls, mounts, creates namespaces, manages networking).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The container process&lt;/strong&gt; (your app).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Linux kernel&lt;/strong&gt; (shared substrate that you still trust with your life).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In rootful Docker, the daemon runs as root. In rootless Docker, the daemon runs as a normal user and uses user namespaces plus helper components to do the namespace work without elevated privileges.&lt;/p&gt;

&lt;p&gt;In practice, rootless setups commonly lean on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RootlessKit&lt;/strong&gt; for unprivileged namespaces and port forwarding (&lt;a href="https://github.com/rootless-containers/rootlesskit" rel="noopener noreferrer"&gt;RootlessKit&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;slirp4netns&lt;/strong&gt; for user-mode networking (&lt;a href="https://github.com/rootless-containers/slirp4netns" rel="noopener noreferrer"&gt;slirp4netns&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fuse-overlayfs&lt;/strong&gt; for overlay filesystem behavior without privileged kernel overlay usage (&lt;a href="https://github.com/containers/fuse-overlayfs" rel="noopener noreferrer"&gt;fuse-overlayfs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those building blocks explain the trade: you’re often swapping kernel fast paths (iptables, kernel overlayfs) for userland components. Security gets cleaner in one dimension. Performance and “weird edge-case behavior” sometimes get worse in others.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rootless vs userns-remap: same ingredient, different blast radius
&lt;/h3&gt;

&lt;p&gt;People mash these together because both use user namespaces. But they solve different problems.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rootless&lt;/strong&gt; changes the daemon’s privilege level. Big deal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;userns-remap&lt;/strong&gt; keeps the daemon rootful, but remaps container UIDs/GIDs so container root maps to an unprivileged host range.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your auditor asked “prove container root isn’t host root,” either can be part of that story. If you’re specifically worried about the Docker daemon being a loaded gun, rootless is the more direct move.&lt;/p&gt;

&lt;p&gt;This is one of those things where the boring answer is actually the right one: &lt;strong&gt;pick based on what you’re afraid of&lt;/strong&gt;. If your org is worried about daemon exposure and “docker group == root,” rootless is the cleaner story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites (the production stuff, not the happy path)
&lt;/h2&gt;

&lt;p&gt;Before you install anything, check these or you’ll end up with an “it works on my laptop” rootless daemon that flakes out under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Kernel + cgroups reality check
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;On a modern distro, you should be thinking &lt;strong&gt;cgroup v2&lt;/strong&gt;. Rootless resource controls are much saner there.&lt;/li&gt;
&lt;li&gt;On older hosts with cgroup v1 weirdness, treat rootless as a project, not a checkbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify what you’re running: &lt;code&gt;stat -fc %T /sys/fs/cgroup&lt;/code&gt; (you want &lt;code&gt;cgroup2fs&lt;/code&gt; for v2).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) A dedicated service user (don’t run rootless as “whoever logged in”)
&lt;/h3&gt;

&lt;p&gt;Create a non-login service user like &lt;code&gt;docker&lt;/code&gt; or &lt;code&gt;dockerd&lt;/code&gt; and treat it as infrastructure.&lt;/p&gt;

&lt;p&gt;I’m opinionated about this because access boundaries that depend on “who happened to install it” don’t survive staff changes. Or weekend debugging.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Subordinate UID/GID ranges (you’ll need them for user namespaces)
&lt;/h3&gt;

&lt;p&gt;Even in rootless land, you still care about &lt;code&gt;/etc/subuid&lt;/code&gt; and &lt;code&gt;/etc/subgid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Concrete check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confirm entries exist: &lt;code&gt;grep -E '^(docker|dockerd):' /etc/subuid /etc/subgid&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don’t have these, you don’t have a real user-namespace mapping story. You have vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install (Rootless Docker) with systemd user services
&lt;/h2&gt;

&lt;p&gt;I’m not going to retype Docker’s installation steps. Use the official rootless docs for your distro: &lt;a href="https://docs.docker.com/engine/security/rootless/" rel="noopener noreferrer"&gt;Docker rootless mode&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What I will do is call out the production pattern that’s missing from a lot of writeups: &lt;strong&gt;run it as a systemd user service&lt;/strong&gt;, and treat the socket as the choke point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safe production pattern: rootless daemon, explicit socket access
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Install and enable the rootless daemon under your dedicated user.&lt;/li&gt;
&lt;li&gt;Ensure the daemon uses a &lt;strong&gt;user-level socket&lt;/strong&gt; (usually under the user’s runtime dir).&lt;/li&gt;
&lt;li&gt;Control who can talk to that socket.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a developer can reach your Docker socket, they can usually get “admin-equivalent” power over whatever that daemon can do. Rootless narrows that equivalence to the daemon’s user. That’s still catastrophic if that user owns prod data volumes.&lt;/p&gt;

&lt;p&gt;Operational note: if you do centralized logging, make sure journald logs for the user service are shipped. Otherwise you’ll have a “secure” setup with zero usable telemetry when something goes sideways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rootless Docker vs userns-remap: what’s the difference, and which should I use in production?
&lt;/h2&gt;

&lt;p&gt;This is the decision table I wish teams put in their internal runbooks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Rootless Docker&lt;/th&gt;
&lt;th&gt;userns-remap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daemon privilege&lt;/td&gt;
&lt;td&gt;Non-root&lt;/td&gt;
&lt;td&gt;Root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary security win&lt;/td&gt;
&lt;td&gt;Smaller blast radius if daemon is compromised&lt;/td&gt;
&lt;td&gt;Container root maps to unprivileged host IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops friction&lt;/td&gt;
&lt;td&gt;Higher (networking, ports, storage can differ)&lt;/td&gt;
&lt;td&gt;Medium (volumes, permissions, some features)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common gotcha&lt;/td&gt;
&lt;td&gt;Port binding, throughput, filesystem backend&lt;/td&gt;
&lt;td&gt;File ownership on bind mounts and named volumes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Hosts where daemon risk is a top concern&lt;/td&gt;
&lt;td&gt;Environments where rootless breaks workloads but you still want UID isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My stance: &lt;strong&gt;if you can run rootless without breaking SLOs, do it&lt;/strong&gt;. If you can’t, don’t force it. Use userns-remap plus hardening layers and move on with your life.&lt;/p&gt;

&lt;h3&gt;
  
  
  About remapping and subordinate user and group IDs
&lt;/h3&gt;

&lt;p&gt;Docker’s &lt;code&gt;userns-remap&lt;/code&gt; maps container UID 0 to an unprivileged host UID/GID range defined in &lt;code&gt;/etc/subuid&lt;/code&gt; and &lt;code&gt;/etc/subgid&lt;/code&gt; (&lt;a href="https://docs.docker.com/engine/security/userns-remap/" rel="noopener noreferrer"&gt;Docker userns-remap docs&lt;/a&gt;). That mapping is the entire point.&lt;/p&gt;

&lt;p&gt;In English: “root in the container” becomes “some random high-numbered UID on the host.”&lt;/p&gt;

&lt;p&gt;That’s a real safety improvement for a broad class of container breakouts that rely on host UID 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable userns-remap on the daemon
&lt;/h3&gt;

&lt;p&gt;Enable it at the daemon level using Docker’s documented configuration flow (&lt;a href="https://docs.docker.com/engine/security/userns-remap/" rel="noopener noreferrer"&gt;userns-remap&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The production gotcha is usually &lt;strong&gt;storage + bind mounts&lt;/strong&gt;. Once IDs are remapped, file ownership semantics change. If your app expects to write to a bind mount owned by real UID 1000, you can end up with permission failures that feel random until you remember you asked the OS to lie about identity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable namespace remapping for a container
&lt;/h3&gt;

&lt;p&gt;Docker supports disabling remapping per container in documented ways. In production, doing that is basically admitting you have a snowflake workload. Use it sparingly. Document why it exists. Put a date on revisiting it. Exceptions have a way of turning into architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  User namespace known limitations
&lt;/h3&gt;

&lt;p&gt;The official docs list limitations and edge cases. Treat them as design constraints, not bugs. The real ops reality is that volume permissions and “who owns what on disk” becomes a first-order concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rootless Docker mode limitations (networking, storage, ports, cgroups)
&lt;/h2&gt;

&lt;p&gt;This is where rootless stops being “security blog advice” and turns into a change request you actually have to defend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ports: the &amp;lt;1024 issue
&lt;/h3&gt;

&lt;p&gt;Binding privileged ports (below &lt;strong&gt;1024&lt;/strong&gt;) is traditionally a root-only operation. Rootless setups often require workarounds (reverse proxy, host-level port forwarding, or running the listener behind something that can bind 80/443).&lt;/p&gt;

&lt;p&gt;If you’re running a single-node service directly on 80/443, you will feel this immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Networking: slirp4netns tradeoffs
&lt;/h3&gt;

&lt;p&gt;Rootless commonly uses user-mode networking via &lt;a href="https://github.com/rootless-containers/slirp4netns" rel="noopener noreferrer"&gt;slirp4netns&lt;/a&gt;. That’s a different data plane than “host bridge + iptables.”&lt;/p&gt;

&lt;p&gt;In production, that can mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lower throughput&lt;/li&gt;
&lt;li&gt;Different MTU behavior&lt;/li&gt;
&lt;li&gt;Debugging that looks nothing like your rootful hosts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your service’s SLO is tightly coupled to throughput or tail latency, benchmark before rollout. I don’t care how many blog posts say it’s “fine.” Measure it on your workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: fuse-overlayfs and filesystem semantics
&lt;/h3&gt;

&lt;p&gt;Rootless often leans on &lt;a href="https://github.com/containers/fuse-overlayfs" rel="noopener noreferrer"&gt;fuse-overlayfs&lt;/a&gt; for overlay filesystem behavior without privileged kernel overlay configuration.&lt;/p&gt;

&lt;p&gt;That’s usually fine for typical web workloads. It can be ugly for IO-heavy jobs.&lt;/p&gt;

&lt;p&gt;A concrete rule: if your containers do sustained disk IO and you care about P99, &lt;strong&gt;rootless is a performance change&lt;/strong&gt;. Treat it like one.&lt;/p&gt;

&lt;h3&gt;
  
  
  cgroups: resource limits are not optional
&lt;/h3&gt;

&lt;p&gt;Rootless without resource limits is just “unprivileged chaos.” Put CPU and memory limits on everything. Even if nobody’s attacking you, a runaway process is operationally indistinguishable from a DoS.&lt;/p&gt;

&lt;p&gt;If you run Kubernetes, you already live here. If you run Docker directly on hosts, this is where the weird 2 a.m. incidents come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rootless Docker security checklist (production)
&lt;/h2&gt;

&lt;p&gt;This is the list I’d want to hand an auditor. It’s also the list I’d want oncall to have when a container starts behaving like it’s possessed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the daemon as a dedicated non-root user&lt;/strong&gt; (not your personal account).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock down socket access&lt;/strong&gt;. Treat “can access the Docker socket” as equivalent to “has admin power over that host.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin and scan base images&lt;/strong&gt;. Use digests, not mutable tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop Linux capabilities&lt;/strong&gt; aggressively. Start from &lt;code&gt;--cap-drop=ALL&lt;/code&gt; and add back only what’s required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable &lt;code&gt;no-new-privileges&lt;/code&gt;&lt;/strong&gt; for containers that don’t need privilege escalation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a read-only filesystem&lt;/strong&gt; where you can (&lt;code&gt;--read-only&lt;/code&gt;) and mount explicit writable tmpfs paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set explicit resource limits&lt;/strong&gt;: CPU, memory, pids limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep seccomp on&lt;/strong&gt;. Don’t use &lt;code&gt;--security-opt seccomp=unconfined&lt;/code&gt; unless you have a signed exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use AppArmor profiles&lt;/strong&gt; where available, and log denials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat secrets as hostile&lt;/strong&gt;: no long-lived secrets in env vars. Prefer short-lived identity and external secret stores.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’m deliberately mixing security and ops here. In real systems they’re the same thing. The “secure” setup that pages you every night gets disabled, and then you’re worse off than when you started.&lt;/p&gt;

&lt;p&gt;For adjacent hardening checklists, see how I structure defense-in-depth for &lt;a href="https://dev.to/glossary/ai-in-production"&gt;AI in production&lt;/a&gt; and the supply chain angle in &lt;a href="https://dev.to/blog/llm-supply-chain-security-checklist"&gt;LLM security&lt;/a&gt;. Same principle: reduce blast radius, then reduce reachable surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker seccomp + AppArmor hardening (still matters in rootless)
&lt;/h2&gt;

&lt;p&gt;Rootless changes who the daemon is. &lt;strong&gt;seccomp and AppArmor change what the container can do.&lt;/strong&gt; Different layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pass a profile for a container (seccomp)
&lt;/h3&gt;

&lt;p&gt;Docker supports passing a custom seccomp profile per container (&lt;a href="https://docs.docker.com/engine/security/seccomp/" rel="noopener noreferrer"&gt;Docker seccomp security profiles&lt;/a&gt;). Use this when a workload needs a syscall Docker blocks by default, but you don’t want to go fully unconfined.&lt;/p&gt;

&lt;h3&gt;
  
  
  Significant syscalls blocked by the default profile
&lt;/h3&gt;

&lt;p&gt;Docker’s default profile blocks a set of “significant syscalls” (their wording) because they show up in breakout chains or are rarely needed by typical apps. The exact list changes over time, but the lesson doesn’t: the default seccomp policy is a baseline. It’s not tuned for your workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run without the default seccomp profile
&lt;/h3&gt;

&lt;p&gt;If you do &lt;code&gt;--security-opt seccomp=unconfined&lt;/code&gt;, write down why, set an expiration date, and add monitoring.&lt;/p&gt;

&lt;p&gt;I’ve seen this exact failure mode in my own infra work. In the deterministic gates I run for this site’s publishing pipeline, “temporary exceptions” become permanent in about &lt;strong&gt;2 weeks&lt;/strong&gt; unless someone is explicitly on the hook to remove them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understand the policies (AppArmor)
&lt;/h3&gt;

&lt;p&gt;AppArmor is mandatory access control at the kernel level. Docker can apply AppArmor profiles to containers (&lt;a href="https://docs.docker.com/engine/security/apparmor/" rel="noopener noreferrer"&gt;Docker AppArmor security profiles&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If you’re on Ubuntu, AppArmor is usually already there. That’s the good news.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load and unload profiles
&lt;/h3&gt;

&lt;p&gt;Docker’s docs cover how profiles are loaded and referenced. The operational point that matters is boring: your deployment system needs a way to ship profiles to hosts, and you need a rollback plan.&lt;/p&gt;

&lt;p&gt;Security controls without rollback are just outage generators.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debug AppArmor
&lt;/h3&gt;

&lt;p&gt;The Docker AppArmor docs call out using &lt;code&gt;dmesg&lt;/code&gt; and &lt;code&gt;aa-status&lt;/code&gt; to debug denials. In production this matters because AppArmor failures look like “my app randomly can’t open a file.”&lt;/p&gt;

&lt;p&gt;You want denials routed to your log pipeline and alert thresholds that don’t spam.&lt;/p&gt;

&lt;p&gt;If you’re also building agent systems, the analogy is direct. &lt;a href="https://dev.to/blog/prompt-injection-regression-testing-ci"&gt;prompt injection&lt;/a&gt; failures look like “the model is being weird.” Without logs, you guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What threats rootless does NOT protect you from
&lt;/h2&gt;

&lt;p&gt;This is the part most teams skip. It’s also where security theater is born.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Malicious images / supply chain&lt;/strong&gt;: Rootless doesn’t stop &lt;code&gt;curl | bash&lt;/code&gt; inside your container from exfiltrating data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kernel CVEs&lt;/strong&gt;: You still share the kernel. Rootless doesn’t change that boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-layer RCE&lt;/strong&gt;: If your service has RCE, the attacker can still steal data reachable by that service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSRF to cloud metadata&lt;/strong&gt;: Rootless doesn’t prevent your app from hitting &lt;code&gt;169.254.169.254&lt;/code&gt; if it can route there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets exposure&lt;/strong&gt;: Secrets in env vars, baked into images, or sprayed into logs are still your fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral movement over the network&lt;/strong&gt;: Rootless doesn’t segment networks or enforce egress controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writable Docker socket access&lt;/strong&gt;: If an attacker can access your Docker socket, rootless just changes which host user they become. That can still be enough to own the box.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If this feels repetitive, good. The pattern repeats across domains. In agent systems, people fixate on one control and ignore the kill chain. See &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; security discussions. Same shape, different nouns.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify protections are actually active
&lt;/h2&gt;

&lt;p&gt;If you can’t verify it, you can’t defend it.&lt;/p&gt;

&lt;p&gt;Here’s a practical verification list you can run on a host and capture in a ticket.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Daemon is rootless&lt;/strong&gt;: confirm the daemon process isn’t running as UID 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User namespace mapping&lt;/strong&gt;: verify &lt;code&gt;/etc/subuid&lt;/code&gt; and &lt;code&gt;/etc/subgid&lt;/code&gt; entries exist for the daemon user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seccomp active&lt;/strong&gt;: run a container and inspect whether seccomp is in effect. If it’s unconfined, you should know why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AppArmor active&lt;/strong&gt;: confirm the profile is loaded and applied, and that denials show up in logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Socket permissions&lt;/strong&gt;: check who can access the Docker socket. Make sure it matches your least-privilege model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This kind of checklist thinking is how I run my own infra. I maintain benchmark pages at &lt;a href="https://dev.to/llm-benchmarks"&gt;kunalganglani.com/llm-benchmarks&lt;/a&gt;, and the recurring lesson is that “defaults” only stay true if you keep verifying them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safe production patterns for running the rootless daemon
&lt;/h2&gt;

&lt;p&gt;If you want a setup that won’t get quietly weakened over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the daemon as a dedicated user.&lt;/li&gt;
&lt;li&gt;Make the socket path explicit, and gate access via group membership or systemd socket activation rules.&lt;/li&gt;
&lt;li&gt;Prefer immutable infra patterns. Reprovision hosts instead of hand-editing them.&lt;/li&gt;
&lt;li&gt;Patch cadence matters more than perfection. Rootless reduces blast radius. Patching reduces the chance you test that blast radius.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your org is building more automation around deployments, read &lt;a href="https://dev.to/glossary/ci-cd"&gt;CI/CD&lt;/a&gt; as “security plumbing,” not “developer convenience.” That’s where the real hardening lives.&lt;/p&gt;

&lt;p&gt;My prediction: by late &lt;strong&gt;2027&lt;/strong&gt;, auditors will treat “rootless + seccomp + AppArmor + socket controls” as the baseline the way “TLS everywhere” became boring. The teams that win won’t be the ones with the fanciest container runtime. They’ll be the ones that can prove, continuously, what’s actually enforced on every host.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/docker-rootless-mode-security?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=docker-rootless-mode-security" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>containers</category>
      <category>hardening</category>
      <category>linuxsecurity</category>
    </item>
  </channel>
</rss>
