<?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: Oscar Kandir</title>
    <description>The latest articles on DEV Community by Oscar Kandir (@oscar_kandir_4ce4f97c3d43).</description>
    <link>https://dev.to/oscar_kandir_4ce4f97c3d43</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%2F3843684%2F503e696a-911d-41ce-a701-9dba0466176d.jpg</url>
      <title>DEV Community: Oscar Kandir</title>
      <link>https://dev.to/oscar_kandir_4ce4f97c3d43</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oscar_kandir_4ce4f97c3d43"/>
    <language>en</language>
    <item>
      <title>Your ownership records are already out of date: building Drift on FalkorDB</title>
      <dc:creator>Oscar Kandir</dc:creator>
      <pubDate>Sun, 18 Oct 2026 09:18:47 +0000</pubDate>
      <link>https://dev.to/oscar_kandir_4ce4f97c3d43/your-ownership-records-are-already-out-of-date-building-drift-on-falkordb-234p</link>
      <guid>https://dev.to/oscar_kandir_4ce4f97c3d43/your-ownership-records-are-already-out-of-date-building-drift-on-falkordb-234p</guid>
      <description>&lt;p&gt;I spent four days, solo, asking Kubernetes one simple question. Who actually owns this code? It turns out the answer isn't what its files say.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes lists 157 approvers in its OWNERS files. 48 of them haven't touched the main repo in a year.&lt;/li&gt;
&lt;li&gt;I built Drift on FalkorDB. It finds the right reviewer for a change 78% of the time, compared with 37% if you pick from the OWNERS files (tested on 400 real pull requests). It also drafts fixes to those files and tests them on a copy of the graph first.&lt;/li&gt;
&lt;li&gt;Along the way I hit a FalkorDB bug that made my first result look better than it was, and I dropped a claim that didn't hold up.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Every big engineering team writes down who owns what. GitHub has CODEOWNERS, and Kubernetes has an &lt;code&gt;OWNERS&lt;/code&gt; file in almost every folder, listing the people who can approve changes there. That's how pull requests get routed, and it's how a newcomer figures out who to ask.&lt;/p&gt;

&lt;p&gt;The trouble is that these files go stale. People change teams, take a break or just get busy, and nobody goes back to edit the file. Meanwhile someone new quietly becomes the person who actually knows that code, and they never get added. Nothing compares the record with what's really happening, so nobody notices.&lt;/p&gt;

&lt;p&gt;I built Drift for Graph Hacks (WeMakeDevs × FalkorDB) to do exactly that comparison, on a real organisation. It's live at &lt;a href="https://drift-egny.onrender.com" rel="noopener noreferrer"&gt;https://drift-egny.onrender.com&lt;/a&gt; and the code is at &lt;a href="https://github.com/oscarosk/drift" rel="noopener noreferrer"&gt;https://github.com/oscarosk/drift&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftxz85k8k2u8j00gkupvf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftxz85k8k2u8j00gkupvf.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first number
&lt;/h2&gt;

&lt;p&gt;Once everything was in a graph, my first query was the obvious one. Which listed approvers have done nothing in the last year? No reviews, no &lt;code&gt;/lgtm&lt;/code&gt;, no &lt;code&gt;/approve&lt;/code&gt;, no merged pull requests in kubernetes/kubernetes.&lt;/p&gt;

&lt;p&gt;The answer was &lt;strong&gt;48 of 157&lt;/strong&gt;, and every one of them is still listed. (I try to say "inactive here" rather than "gone", because plenty of them are probably busy in other Kubernetes repos.)&lt;/p&gt;

&lt;p&gt;Everything else in this post is what I built on top of that number, and how I tried to make sure I wasn't fooling myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data, and why it had to be a graph
&lt;/h2&gt;

&lt;p&gt;All of it is public. I used the team list (&lt;code&gt;sigs.yaml&lt;/code&gt;, 24 teams), every &lt;code&gt;OWNERS&lt;/code&gt; and &lt;code&gt;OWNERS_ALIASES&lt;/code&gt; file, about 680 enhancement proposals (the decision records Kubernetes calls KEPs), every merged pull request since October 2024 with the files it changed, and the reviews and &lt;code&gt;/lgtm&lt;/code&gt; and &lt;code&gt;/approve&lt;/code&gt; comments on those pull requests from the GitHub API. I only store GitHub handles. No names, emails or comment text.&lt;/p&gt;

&lt;p&gt;The whole product is about comparing two sides of the same graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(:Person)-[:APPROVER_OF]-&amp;gt;(:Dir)                                                what the records say
(:Person)-[:REVIEWED|LGTM|APPROVED_PR|AUTHORED_PR]-&amp;gt;(:PR)-[:TOUCHES]-&amp;gt;(:Dir)    what people actually do
(:Dir)-[:INSIDE]-&amp;gt;(:Dir)                                                        OWNERS inheritance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2a77gc8k2j9pkpnbudqt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2a77gc8k2j9pkpnbudqt.png" alt=" " width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reason it needs a graph is inheritance. If a folder has no approvers of its own, approval falls back to the nearest parent folder that has some, and so on up the tree. In Cypher, "everyone who reviewed anything at or below this folder" is a single pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;p:&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:REVIEWED&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;LGTM&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;APPROVED_PR&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;pr:&lt;/span&gt;&lt;span class="n"&gt;PR&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:TOUCHES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Dir&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:INSIDE&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="n"&gt;$p&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In SQL that's a recursive query I really wouldn't want to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people would actually use
&lt;/h2&gt;

&lt;p&gt;Nobody wakes up wanting to know how stale ownership is. What they want to know is who should review their change.&lt;/p&gt;

&lt;p&gt;So that's the main feature. You paste the files you changed, and Drift ranks people by their reviews and approvals in those folders over the last six months, preferring people who know more of your change. It also shows why it picked them (this person reviewed this pull request, which changed this folder), and draws the evidence as a graph, with the inactive listed approvers in plum.&lt;/p&gt;

&lt;p&gt;[IMAGE: upload evidence-graph.png here]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumj245eb5ixznk3ddr5o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumj245eb5ixznk3ddr5o.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Measuring it, and the bug
&lt;/h2&gt;

&lt;p&gt;Screenshots can look convincing without proving anything, so I tested it against what actually happened.&lt;/p&gt;

&lt;p&gt;I took 400 pull requests merged between July and October 2026. For each one, Drift only got to see activity from before that pull request (never including the author), and I checked whether its top three included someone who really typed &lt;code&gt;/approve&lt;/code&gt; on it. The comparison is what a contributor does today, which is pick from the OWNERS files.&lt;/p&gt;

&lt;p&gt;My first run said &lt;strong&gt;86%&lt;/strong&gt;. That felt too good, so I wrote a quick leak check. For a sample of pull requests, was any of the evidence from after the merge? Some of it was.&lt;/p&gt;

&lt;p&gt;It took a while to find the cause, and it turned out to be FalkorDB itself. If you put a range index on a string property, a range filter ignores its upper bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;p:&lt;/span&gt;&lt;span class="n"&gt;PR&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p.s&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;p:&lt;/span&gt;&lt;span class="n"&gt;PR&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p.s&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-05'&lt;/span&gt; &lt;span class="ow"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p.s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-10'&lt;/span&gt; &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 15, expected 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same query on an integer property is fine. I'd been filtering on date strings, so "before this pull request" was quietly letting the future in. I checked it on FalkorDB 4.18.3 and 4.22.1, wrote a short script that reproduces it, and switched every date filter to integers (&lt;code&gt;PR.day = 20260910&lt;/code&gt;). There's a test now that fails if the leak ever comes back.&lt;/p&gt;

&lt;p&gt;Here's the honest result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Who was in the top 3?&lt;/th&gt;
&lt;th&gt;Right person first&lt;/th&gt;
&lt;th&gt;Right person in top 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;td&gt;53%&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three people from the OWNERS files&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;37%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The three busiest approvers overall&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs03k7mjbm5yqmyfnsi38.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs03k7mjbm5yqmyfnsi38.png" alt=" " width="800" height="191"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So it's about twice as good as the records. I kept the 86% story in the README on purpose. Honestly, the wrong number taught me more than the right one did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drafting the fix, and testing it on a copy
&lt;/h2&gt;

&lt;p&gt;Pointing at problems only gets you so far, so Drift also drafts fixes. For the folders that have drifted most, it moves inactive approvers to &lt;code&gt;emeritus_approvers&lt;/code&gt; (Kubernetes' own way of saying someone stepped back) and adds people who actually review there. It edits the real file text, keeps the comments, and exports a patch that passes &lt;code&gt;git apply --check&lt;/code&gt;, with the evidence next to each line.&lt;/p&gt;

&lt;p&gt;But removing approvers can change where approval requests end up, and I didn't want Drift suggesting something that quietly breaks a folder. This is where &lt;code&gt;GRAPH.COPY&lt;/code&gt; became my favourite FalkorDB feature. Before you download anything, Drift copies the whole graph, applies the patch to the copy, runs the same "where does approval go?" query on both, compares them and deletes the copy. In code it's pretty small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# where does every approval go today?
&lt;/span&gt;    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&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;whatif_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&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="c1"&gt;# GRAPH.COPY: a full, isolated copy
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tools&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="nf"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# e.g. move inactive approvers to emeritus
&lt;/span&gt;        &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# the same query, on the copy
&lt;/span&gt;    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;graph&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="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;# the copy never outlives the request
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;coverage&lt;/code&gt; is one query that walks each folder up its ownership chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;repo:&lt;/span&gt; &lt;span class="s1"&gt;'kubernetes'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d.has_owners&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:INSIDE&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;o:&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;OPTIONAL&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;p:&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:APPROVER_OF&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;d.path&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="ss"&gt;),&lt;/span&gt; &lt;span class="n"&gt;o.path&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o.no_parent_owners&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;p.handle&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the 40 drafted fixes, &lt;strong&gt;12 areas&lt;/strong&gt; whose approval requests only reached inactive people get unstuck, and none get stuck. It takes a couple of seconds and never touches the real graph.&lt;/p&gt;

&lt;p&gt;The same trick answers "what if?" questions. If one particular approver stepped back, 6 areas would be left waiting on inactive people. For 50 of the 60 busiest approvers, nothing would get stuck at all. Kubernetes turned out to be more resilient than I expected, with a few sharp edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real teams, and a claim I dropped
&lt;/h2&gt;

&lt;p&gt;The official labels say which team owns each folder. I wanted to know which folders are really worked on by the same group of people, so I linked two folders whenever the same people work in both, and ran FalkorDB's &lt;code&gt;algo.labelPropagation&lt;/code&gt; on that.&lt;/p&gt;

&lt;p&gt;It found 19 working groups, and 13 of them cross the official team lines. To check they weren't just noise, I built the groups from activity before July and tested them on later pull requests. 47% of folder pairs changed together in a pull request landed in the same group, compared with 15% by chance.&lt;/p&gt;

&lt;p&gt;I also tested a second idea, that pull requests crossing groups need more approvers. The first time I ran it, it looked like three times as many. Then I re-ran it on a fresh download of the data and the effect disappeared. So I dropped it, and the README says so. It was the most exciting finding I had, which is exactly why I didn't trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent never writes Cypher
&lt;/h2&gt;

&lt;p&gt;Drift has an Ask box and an MCP server, so Claude Desktop or Cursor can use the same tools. I followed the approach from the organisers' guide on agents that reason over graphs. The model picks one of eight fixed queries and fills in the arguments, but never writes a query itself. Every folder, person or KEP it mentions is looked up in the graph before anything runs, so a typo gets "did you mean…" instead of a confident wrong answer. Answers are saved back into the graph as &lt;code&gt;(:Finding)&lt;/code&gt; nodes, linked to what they're about, so next time Drift can tell you what's changed.&lt;/p&gt;

&lt;p&gt;The piece I'd copy into any project is the check on the model's sentence. It's just a few regular expressions and set differences:&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;HANDLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@([A-Za-z0-9][A-Za-z0-9-]{0,38})&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;invented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HANDLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;known_handles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&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;invented&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&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;named people not in the result: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invented&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# ...the same for folders, KEP numbers, and any number of two digits or more
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a sentence fails, the reader gets a plainer one built straight from the query results instead. I'd rather Drift sound a bit flat than say something wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that went wrong (the less glamorous ones)
&lt;/h2&gt;

&lt;p&gt;Not everything was a clever bug. My very first build failed with "connection refused" because Docker wasn't running. FalkorDB Cloud doesn't allow the &lt;code&gt;KEYS&lt;/code&gt; command, which one of my tests used, so I switched it to &lt;code&gt;GRAPH.LIST&lt;/code&gt;. My first deployment landed in a US region and felt slow from India, so I rebuilt it in Singapore, next to the database in Mumbai. And the CI badge was red for a whole day before I noticed that GitHub runs &lt;code&gt;pytest&lt;/code&gt; slightly differently from how I ran it locally, so it couldn't even find my code. It was one missing line in &lt;code&gt;pytest.ini&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Compare against what people actually do today, not against a strawman. The honest question was never "graph vs RAG". It was "is this better than what a contributor already does?"&lt;/p&gt;

&lt;p&gt;Be suspicious of good numbers. The 86% was a bug, and the leak check that caught it took about ten minutes to write.&lt;/p&gt;

&lt;p&gt;Throw out results that don't reproduce, especially the exciting ones.&lt;/p&gt;

&lt;p&gt;And graph databases earn their place through the shape of the problem as much as speed. Inheritance chains, two snapshots of the organisation in one database, and copy-edit-compare what-ifs are what made this a graph problem for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things you can reuse on your own graph project
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep the past as a second graph. FalkorDB holds many graphs in one instance, so a snapshot from a year ago can sit next to today's. Run the same queries on both and you can test predictions against what really happened.&lt;/li&gt;
&lt;li&gt;Copy, edit, compare. &lt;code&gt;GRAPH.COPY&lt;/code&gt; turns any "what if?" into the same query run twice. Delete the copy in a &lt;code&gt;finally&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;Test with a time split, then try to break it. Only give the system what it would have known at the time, and write a leak check. Mine caught a real bug.&lt;/li&gt;
&lt;li&gt;Filter date ranges on integers. Until the string-index bug is fixed, store &lt;code&gt;20260910&lt;/code&gt; rather than &lt;code&gt;"2026-09-10"&lt;/code&gt; for anything you filter by range.&lt;/li&gt;
&lt;li&gt;Let the model choose queries, never write them. Use fixed, parameterised queries, look names up in the graph first, and check every sentence against the result.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;a href="https://drift-egny.onrender.com" rel="noopener noreferrer"&gt;https://drift-egny.onrender.com&lt;/a&gt; (it's on free hosting, so the first load can take a minute). Click &lt;em&gt;Find a reviewer&lt;/em&gt;, then &lt;em&gt;Try an example&lt;/em&gt;, or type &lt;code&gt;What if @deads2k stepped back?&lt;/code&gt; into the Ask box.&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/oscarosk/drift" rel="noopener noreferrer"&gt;https://github.com/oscarosk/drift&lt;/a&gt;. It runs locally with Docker, and the README has the full graph model and every Cypher query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you look after an &lt;code&gt;OWNERS&lt;/code&gt; or CODEOWNERS file, I'd love to know whether yours has drifted too. Let me know in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built for Graph Hacks: Context for AI Agents by WeMakeDevs and FalkorDB. I used Claude as an AI coding assistant while building it; there are details in the README.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>falkordb</category>
      <category>graphdatabase</category>
      <category>kubernetes</category>
      <category>ai</category>
    </item>
    <item>
      <title>Stroll Call: a screen-free voice walk companion for older adults, on a local open model</title>
      <dc:creator>Oscar Kandir</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:56:34 +0000</pubDate>
      <link>https://dev.to/oscar_kandir_4ce4f97c3d43/stroll-call-a-screen-free-voice-walk-companion-for-older-adults-on-a-local-open-model-3m6b</link>
      <guid>https://dev.to/oscar_kandir_4ce4f97c3d43/stroll-call-a-screen-free-voice-walk-companion-for-older-adults-on-a-local-open-model-3m6b</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Older people in my family don't use apps, and walk less every year. Step counters don't move them. Someone saying, warmly and specifically, "go to the park gate, rest under the big tree, say hello to the tea seller" does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stroll Call&lt;/strong&gt; is a screen-free voice walk companion for older adults. Every morning a local open-weight model writes one short, personal walk suggestion and reads it aloud. The person walking never touches a screen; the screen time for them is zero.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A family member fills in a small profile once: mobility, real nearby places, real people, likes, and things to avoid (the main road, broken steps, midday heat).&lt;/li&gt;
&lt;li&gt;Each morning the model writes a 40–70 word spoken message using &lt;strong&gt;only&lt;/strong&gt; those places and people, and &lt;strong&gt;never&lt;/strong&gt; anything on the avoid list.&lt;/li&gt;
&lt;li&gt;It remembers how yesterday's walk went: shorter after a tired day, gentle after skipped days, a new route after a good streak.&lt;/li&gt;
&lt;li&gt;After the walk, three fields are logged: minutes, how it felt, and one note.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Honest status:&lt;/strong&gt; the full pipeline (message → voice → memory → next-day adaptation, the setup page, and the eval) was tested end to end with a stand-in model. I have &lt;strong&gt;not&lt;/strong&gt; yet run it on real Gemma with my family member. My connection ran at about 30 kB/s and the model download didn't finish before the deadline. I'll post the real walk and eval results in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/oscarosk" rel="noopener noreferrer"&gt;
        oscarosk
      &lt;/a&gt; / &lt;a href="https://github.com/oscarosk/stroll-call" rel="noopener noreferrer"&gt;
        stroll-call
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Stroll Call&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A screen-free voice walk companion for older adults. Every morning a &lt;strong&gt;local open-weight model&lt;/strong&gt; writes a short, personal walk suggestion, remembers how yesterday's walk went, and reads it aloud. The person walking never touches a screen.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Runs on an ordinary laptop with &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; and Gemma. No cloud model, no subscription.&lt;/li&gt;
&lt;li&gt;Their routine, places, and health notes stay at home.&lt;/li&gt;
&lt;li&gt;Works in any language the model and voice support: change &lt;code&gt;"language"&lt;/code&gt; in &lt;code&gt;profile.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass (Oct 5–11, 2026).&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Setup (Windows 11, 8 GB RAM)&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Install Ollama from &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;https://ollama.com&lt;/a&gt;, then in a terminal
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;ollama pull gemma3:4b
ollama run gemma3:4b "hello"
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
If that is slow on your machine, use &lt;code&gt;gemma3:1b&lt;/code&gt; and set &lt;code&gt;OLLAMA_MODEL=gemma3:1b&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Install Python 3.10+, then in this folder
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
copy profile.example.json profile.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;Edit…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/oscarosk/stroll-call" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model:&lt;/strong&gt; Gemma, running locally through &lt;strong&gt;Ollama&lt;/strong&gt;. No cloud model and no API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; Python. &lt;code&gt;nudge.py&lt;/code&gt; builds the prompt from the profile and the last 5 walks; &lt;code&gt;memory.py&lt;/code&gt; stores walks (a local JSON file, or MongoDB Atlas if configured); &lt;code&gt;speak.py&lt;/code&gt; turns the text into audio (an offline Windows voice, or ElevenLabs if configured); &lt;code&gt;app.py&lt;/code&gt; is a one-button Flask setup page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety:&lt;/strong&gt; the prompt allows only listed places and people, bans the avoid list, gives no medical advice, and never pushes after a tired day. Markdown is stripped before speaking so the voice never reads "asterisk asterisk".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluation:&lt;/strong&gt; &lt;code&gt;eval.py&lt;/code&gt; generates messages for four situations (first day, good streak, tired yesterday, two skipped days) and checks spoken length, real places only, avoid-list mentions (target 0%), memory of the last walk, latency and tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracing:&lt;/strong&gt; optional Sentry agent tracing wraps each run (load walks → model call with tokens and latency → voice → save). If the voice fails, the text is still saved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy:&lt;/strong&gt; an elderly person's daily movements and "knee hurt today" notes stay at home, never on someone else's server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; free to run, with no subscription. That matters for families with elders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline:&lt;/strong&gt; it runs on an ordinary 8 GB laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any language:&lt;/strong&gt; change one field in the profile and everything she hears changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control:&lt;/strong&gt; I can read and change every rule, swap models, and re-run the eval myself, instead of hoping a vendor changes behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built this with an AI coding agent: idea selection after reviewing the other entries, the code, and an end-to-end test with a stand-in model.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>My AI agent was waiting in line: how I traced and fixed hidden sequential LLM calls with SigNoz on Windows</title>
      <dc:creator>Oscar Kandir</dc:creator>
      <pubDate>Sun, 19 Jul 2026 16:39:13 +0000</pubDate>
      <link>https://dev.to/oscar_kandir_4ce4f97c3d43/my-ai-agent-was-waiting-in-line-how-i-traced-and-fixed-hidden-sequential-llm-calls-with-signoz-on-ia9</link>
      <guid>https://dev.to/oscar_kandir_4ce4f97c3d43/my-ai-agent-was-waiting-in-line-how-i-traced-and-fixed-hidden-sequential-llm-calls-with-signoz-on-ia9</guid>
      <description>&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;The agent is small on purpose. You give it a topic, and it does three things: it asks an LLM to break the topic into three sub-questions, answers each of those questions with a separate LLM call, then makes one final call to combine the answers into a short summary. It's built with FastAPI and uses OpenAI's &lt;code&gt;gpt-4o-mini&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's five LLM calls for a single request. It always returned a sensible answer, so functionally it was "done." But a normal HTTP log only tells me the whole request took ten seconds, not which of those five calls ate the time, how many tokens each one burned, or whether any of them could have run at the same time. That's the gap I wanted to close, and it's exactly what tracing is for: seeing inside one request, step by step.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting up SigNoz on Windows (WSL 2)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What you'll need to follow along:&lt;/strong&gt; Windows with WSL 2 (I used Ubuntu), Docker, Python 3, and an OpenAI API key.&lt;/p&gt;

&lt;p&gt;I self-hosted SigNoz rather than using a cloud version, because I wanted the whole stack on my own machine. On Windows that means working inside WSL 2, and the first thing I learned is that the Docker you &lt;em&gt;think&lt;/em&gt; you have isn't always the Docker you can use.&lt;/p&gt;

&lt;p&gt;I had Docker Desktop installed, but running &lt;code&gt;docker&lt;/code&gt; inside my Ubuntu WSL distro gave me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The command 'docker' could not be found in this WSL 2 distro.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker Desktop keeps its engine in its own separate WSL distro, and mine wasn't wired into Ubuntu. Rather than fight the Docker Desktop integration, I installed Docker natively inside Ubuntu instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a &lt;code&gt;wsl --shutdown&lt;/code&gt; and reopening Ubuntu, &lt;code&gt;docker ps&lt;/code&gt; finally returned a clean, empty table. That was the sign the daemon was actually reachable.&lt;/p&gt;

&lt;p&gt;Then SigNoz itself, using their Foundry CLI (the current install method; the older &lt;code&gt;install.sh&lt;/code&gt; script is deprecated):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://signoz.io/foundry.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I created a &lt;code&gt;casting.yaml&lt;/code&gt; describing the install, ran &lt;code&gt;foundryctl cast -f casting.yaml&lt;/code&gt;, and after a few minutes of image pulls, &lt;code&gt;docker ps&lt;/code&gt; showed the full stack healthy: SigNoz on port 8080, the OTel collector on 4317/4318. One quirk: the &lt;code&gt;signoz-mcp&lt;/code&gt; container shows as &lt;code&gt;unhealthy&lt;/code&gt;, but that's a cosmetic healthcheck issue, not a real failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdx7llwgdbc4rw3g1nusp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdx7llwgdbc4rw3g1nusp.png" alt=" " width="800" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's how the whole thing fits together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   POST /research
        │
        ▼
┌──────────────────┐
│  FastAPI agent   │   plan → answer ×3 → combine  (5 LLM calls)
│  (WSL 2 host)    │
└────────┬─────────┘
         │  OpenTelemetry SDK
         │  OTLP → localhost:4318
         ▼
┌──────────────────────────┐
│  SigNoz OTel Collector   │   (Docker, port 4317/4318)
└────────┬─────────────────┘
         ▼
┌──────────────┐      ┌──────────────────┐
│  ClickHouse  │◄─────│   SigNoz UI      │  traces · metrics
│  (storage)   │      │   :8080          │  dashboards · alerts
└──────────────┘      └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Testing the pipe before trusting it
&lt;/h2&gt;

&lt;p&gt;Before I instrumented my real app, I wanted to know the telemetry pipeline actually worked. So I fired a handful of test traces straight at the collector with &lt;code&gt;telemetrygen&lt;/code&gt;, over the Docker network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; signoz-network &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest &lt;span class="se"&gt;\&lt;/span&gt;
  traces &lt;span class="nt"&gt;--otlp-endpoint&lt;/span&gt; signoz-ingester-1:4317 &lt;span class="nt"&gt;--otlp-insecure&lt;/span&gt; &lt;span class="nt"&gt;--traces&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within about thirty seconds, a &lt;code&gt;telemetrygen&lt;/code&gt; service showed up in SigNoz with five traces. That confirmed the whole path was working: collector to ClickHouse to the UI. It paid off later. When my own app's data was slow to appear, I already knew the pipeline was fine and the problem was my config.&lt;/p&gt;

&lt;p&gt;[SCREENSHOT: telemetrygen traces in SigNoz (optional)]&lt;/p&gt;




&lt;h2&gt;
  
  
  Instrumenting the agent
&lt;/h2&gt;

&lt;p&gt;With SigNoz running, I needed the agent to actually send it data. OpenTelemetry has two levels of this, and I used both.&lt;/p&gt;

&lt;p&gt;First, auto-instrumentation, the low-effort win. A couple of installs and a wrapper command, no code changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;opentelemetry-distro opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi
opentelemetry-bootstrap &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ran the app &lt;em&gt;through&lt;/em&gt; the OpenTelemetry wrapper instead of plain uvicorn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;opentelemetry-instrument uvicorn app:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that trips people up: my app runs on the WSL host, not inside Docker, so the telemetry endpoint is &lt;code&gt;localhost:4318&lt;/code&gt;, not the container name &lt;code&gt;signoz-ingester-1&lt;/code&gt; you'd use from inside the Docker network. Getting that wrong is a silent failure: data just never shows up, with no error.&lt;/p&gt;

&lt;p&gt;That alone got the incoming FastAPI request and the OpenAI calls into SigNoz. But auto-instrumentation gives you generic spans, "chat gpt-4o-mini" five times. It can't tell you &lt;em&gt;which&lt;/em&gt; step of my pipeline each call belongs to. So I added manual spans to label the stages and attach the numbers I actually cared about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_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="n"&gt;prompt&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="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="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_name&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;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&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;role&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;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm.total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm.cost_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE_IN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completion_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE_OUT&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every LLM span carries its real token count and dollar cost, and the pipeline stages (&lt;code&gt;plan_questions&lt;/code&gt;, &lt;code&gt;answer_questions&lt;/code&gt;, &lt;code&gt;combine_summary&lt;/code&gt;) show up as named spans in the trace instead of anonymous HTTP calls. (In my runs, a single research request cost about $[FILL IN from your llm.cost_usd spans] in tokens.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftoxykewbkbcsyhl7iwcz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftoxykewbkbcsyhl7iwcz.png" alt=" " width="800" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqsz6vuaxpukcdag9w1iu.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqsz6vuaxpukcdag9w1iu.jpeg" alt=" " width="798" height="397"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking inside: the staircase
&lt;/h2&gt;

&lt;p&gt;Once the custom spans were flowing, I opened one &lt;code&gt;/research&lt;/code&gt; trace in SigNoz and finally saw where those ten seconds went. The flame graph made it obvious in a way no log ever could.&lt;/p&gt;

&lt;p&gt;The five LLM calls weren't overlapping. They were running one after another, in a perfect staircase. &lt;code&gt;plan_questions&lt;/code&gt; finished, &lt;em&gt;then&lt;/em&gt; the first answer call started, &lt;em&gt;then&lt;/em&gt; the second, &lt;em&gt;then&lt;/em&gt; the third, &lt;em&gt;then&lt;/em&gt; the combine step. Each step waited for the one before it.&lt;/p&gt;

&lt;p&gt;The numbers told the story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total request: &lt;strong&gt;10.48s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;answer_questions&lt;/code&gt; alone: &lt;strong&gt;6.54s&lt;/strong&gt;, more than half the entire request&lt;/li&gt;
&lt;li&gt;Inside it, three separate answer calls running back to back (roughly 2.76s, 1.33s, and 2.45s)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the thing that made it click: those three answer calls are completely independent. Answering one sub-question doesn't depend on the answer to another. They were only running one at a time because I'd written a plain &lt;code&gt;for&lt;/code&gt; loop. That's the most natural way to write it, and the most wasteful. My agent was making the LLM wait in line for no reason.&lt;/p&gt;

&lt;p&gt;I hadn't planned this bug. I didn't even know it was there until the trace drew it for me. That's the part that sold me on tracing: the inefficiency wasn't in my code review or my logs. It was sitting in the shape of the waterfall, plain as day.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ihqs9g0ueqzddgu0tnt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ihqs9g0ueqzddgu0tnt.png" alt=" " width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: stop waiting in line
&lt;/h2&gt;

&lt;p&gt;The fix followed directly from what the trace showed. If the three answer calls are independent, they shouldn't run one at a time. They should run at once. I swapped the sequential &lt;code&gt;for&lt;/code&gt; loop for a thread pool so all three fire in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&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;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ask_llm_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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;llm.answer_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer in 2 sentences: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;result&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this is where I hit a real gotcha, and it's one worth knowing if you trace anything threaded: OpenTelemetry's trace context doesn't automatically cross into new threads. The first time I ran the parallel version, the three answer spans detached and floated off as their own separate traces instead of nesting under the request, and the waterfall fell apart.&lt;/p&gt;

&lt;p&gt;The fix was to capture the current context before spawning the threads and re-attach it inside each worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_llm_ctx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ask_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;detach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, I re-ran the same request and opened the new trace. The staircase was gone. The three answer calls now started together and overlapped. &lt;code&gt;answer_questions&lt;/code&gt; collapsed from 6.54s down to about 1.65s, roughly the length of its single slowest call instead of the sum of all three.&lt;/p&gt;

&lt;p&gt;The whole request dropped from &lt;strong&gt;10.48s to 5.62s&lt;/strong&gt;, about a 46% cut, from a change that only worked because a trace showed me where to make it.&lt;/p&gt;

&lt;p&gt;Here's the full endpoint after the fix, so you can reproduce it (the only real change from the original is that step 2 runs in a thread pool instead of a plain &lt;code&gt;for&lt;/code&gt; loop):&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;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detach&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_current&lt;/span&gt;

&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ai-research-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/research&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;research&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;research_pipeline&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;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;research.topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# 1. plan: break the topic into 3 sub-questions
&lt;/span&gt;        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm.plan&lt;/span&gt;&lt;span class="sh"&gt;"&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;Give 3 short sub-questions about: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. One per line, no numbering.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()][:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="c1"&gt;# 2. answer: independent calls, run in parallel (the fix)
&lt;/span&gt;        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer_questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&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;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ask_llm_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&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;llm.answer_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer in 2 sentences: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;result&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="c1"&gt;# 3. combine into a summary
&lt;/span&gt;        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;combine_summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm.combine&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;Combine these into a short paragraph:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&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;answers&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgyxebd3aww389gnxyxrc.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgyxebd3aww389gnxyxrc.jpeg" alt=" " width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Beyond a single trace: a dashboard and an alert
&lt;/h2&gt;

&lt;p&gt;One trace showed me the problem, but a trace is a single request. To watch behavior over time, I built a dashboard panel plotting the p95 latency of &lt;code&gt;POST /research&lt;/code&gt; from my trace data (&lt;code&gt;p95(durationNano)&lt;/code&gt;, filtered to the service).&lt;/p&gt;

&lt;p&gt;Because I'd run the slow sequential version &lt;em&gt;and&lt;/em&gt; the fast parallel version, the panel captured the whole story in one line: latency sitting up around 10 seconds, then dropping and settling near 5 after the fix. The optimization I'd just made, visible as a trend instead of a one-off.&lt;/p&gt;

&lt;p&gt;Then I added a guardrail: a trace-based alert that fires if p95 latency goes above 8 seconds at least once in a 5-minute window. The idea is simple: if the sequential slowdown ever creeps back in (a bad refactor, a new blocking call), I want to know before a user does. I wired it to a test webhook and fired a test notification to confirm it actually delivers.&lt;/p&gt;

&lt;p&gt;One small thing worth flagging for anyone following along: SigNoz won't let you save an alert rule without attaching a notification channel, so create the channel first, then the rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fp17r867sac8zo6hmhj.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fp17r867sac8zo6hmhj.jpeg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4bdh4rpeu3cfdfbhsvc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4bdh4rpeu3cfdfbhsvc.png" alt=" " width="800" height="223"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;p&gt;A few things I actually took away from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-instrumentation shows you the shape; custom spans give you the answers.&lt;/strong&gt; The wrapper got data flowing in minutes, but it was generic: five anonymous LLM calls. The insight only appeared once I added my own named spans and attached token and cost numbers. The low-effort version is worth doing first, but it's not where the value is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trace can show you things a code review won't.&lt;/strong&gt; I'd read that &lt;code&gt;for&lt;/code&gt; loop plenty of times and never thought twice about it. It took seeing the staircase in the flame graph to realize those calls were independent and could run in parallel. The bug wasn't hidden in the code. It was hidden in the &lt;em&gt;timing&lt;/em&gt;, and timing is exactly what a trace makes visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Windows, install Docker inside WSL, not around it.&lt;/strong&gt; Fighting Docker Desktop's WSL integration wasn't worth it. A native install inside Ubuntu was simpler and just worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prove your telemetry pipeline before you build on it.&lt;/strong&gt; Before instrumenting anything real, I sent a handful of test traces with &lt;code&gt;telemetrygen&lt;/code&gt; and confirmed they landed in SigNoz. That five-minute check meant that when my app's data didn't show up later, I &lt;em&gt;knew&lt;/em&gt; the pipeline was fine and the problem was my config, not the other way around.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I started the night unable to run &lt;code&gt;docker&lt;/code&gt; in my terminal and ended it watching a trace tell me exactly why my AI agent was twice as slow as it needed to be, then watching that same tool confirm the fix worked. The agent's output never changed; what changed is that I could finally &lt;em&gt;see&lt;/em&gt; what it was doing. That's the whole point: you can't fix what you can't see, and for a multi-step AI agent, a trace is how you see it.&lt;/p&gt;

&lt;p&gt;If you want to try the same thing, everything I used is open source and self-hostable: &lt;a href="https://signoz.io/docs/" rel="noopener noreferrer"&gt;SigNoz&lt;/a&gt;, &lt;a href="https://opentelemetry.io/docs/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt;, and the OpenAI Python SDK. Self-host SigNoz, instrument one real app you already have, and open a single trace. I'd bet you find something you didn't expect, too.&lt;/p&gt;




</description>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>ai</category>
      <category>python</category>
    </item>
  </channel>
</rss>
