<?xml version="1.0" encoding="utf-8" standalone="yes"?><?xml-stylesheet type="text/xsl" href="/rss.xsl"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Distributed Tracing on CtrlSpice</title><link>https://ctrlspice.codes/tags/distributed-tracing/</link><description>Recent content in Distributed Tracing on CtrlSpice</description><generator>Hugo</generator><language>en-ca</language><lastBuildDate>Thu, 10 Sep 2026 02:34:40 -0700</lastBuildDate><atom:link href="https://ctrlspice.codes/tags/distributed-tracing/index.xml" rel="self" type="application/rss+xml"/><item><title>Generating Trace Waterfalls with Recursive CTEs in DuckDB</title><link>https://ctrlspice.codes/building-trace-trees-with-recursive-ctes/</link><pubDate>Thu, 10 Sep 2026 02:34:40 -0700</pubDate><guid>https://ctrlspice.codes/building-trace-trees-with-recursive-ctes/</guid><description>in which we talk about the last time I was accused of witchcraft</description><content:encoded><![CDATA[<figure class="bluesky-embed">
  <div class="bluesky-embed-header">
    <a class="bluesky-embed-avatar-link" href="https://bsky.app/profile/jeremymorrell.dev" aria-label="Jeremy Morrell on Bluesky">
      <img class="bluesky-embed-avatar" src="/building-trace-trees-with-recursive-ctes/jeremy-morrell.jpg" alt="" width="48" height="48" loading="lazy" decoding="async">
    </a>
    <div class="bluesky-embed-identity">
      <a class="bluesky-embed-author" href="https://bsky.app/profile/jeremymorrell.dev">Jeremy Morrell</a>
      <a class="bluesky-embed-handle" href="https://bsky.app/profile/jeremymorrell.dev">@jeremymorrell.dev</a>
    </div>
    <a class="bluesky-embed-logo" href="https://bsky.app/profile/jeremymorrell.dev/post/3lx3sy2nbv22v" aria-label="View this post on Bluesky" title="View on Bluesky">
      <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
        <path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"></path>
      </svg>
    </a>
  </div>
  <blockquote class="bluesky-embed-text">y&rsquo;all <a href="https://bsky.app/profile/ctrlspice.bsky.social">@ctrlspice.bsky.social</a> is doing some SQL dark magic with <a href="https://bsky.app/profile/duckdb.org">@duckdb.org</a>.
This builds and flattens a trace waterfall from the raw OpenTelemetry span data in one SQL query 🤯 (It even handles incomplete traces with orphan subtrees)</blockquote>
  <figcaption class="bluesky-embed-date">
    <a href="https://bsky.app/profile/jeremymorrell.dev/post/3lx3sy2nbv22v"><time datetime="2025-08-23T20:33:26.364Z">August 23, 2025</time></a>
  </figcaption>
</figure>

<p>I think dark magic is a bit generous.
This is intermediate transmutation<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> at best, by which I mean graph traversal.</p>
<p>A trace waterfall shows a request as nested operations over time.
We don&rsquo;t receive the data as a tree, though.
We get individual spans with IDs that describe their relationships, but they can arrive out of order and we have to build the tree ourselves.</p>
<p>Let&rsquo;s do it in SQL and make it DuckDB&rsquo;s problem.</p>
<p>For one small trace, this is what we have, with the repeated trace ID shortened for display:</p>
<table>
	<thead>
			<tr>
					<th>name</th>
					<th style="text-align: right"><code>trace_id</code></th>
					<th style="text-align: right"><code>span_id</code></th>
					<th style="text-align: right"><code>parent_span_id</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>root</td>
					<td style="text-align: right"><code>...0439</code></td>
					<td style="text-align: right">1</td>
					<td style="text-align: right"><code>null</code></td>
			</tr>
			<tr>
					<td>authenticate</td>
					<td style="text-align: right"><code>...0439</code></td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">1</td>
			</tr>
			<tr>
					<td>checkout</td>
					<td style="text-align: right"><code>...0439</code></td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">1</td>
			</tr>
			<tr>
					<td>fetch-user</td>
					<td style="text-align: right"><code>...0439</code></td>
					<td style="text-align: right">4</td>
					<td style="text-align: right">2</td>
			</tr>
	</tbody>
</table>
<p>Stripped of payload fields, this is what we need:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;root&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">0</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;authenticate&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">1</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;fetch-user&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">2</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;checkout&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span></code></pre></div><p>By the end, DuckDB hands the front end the spans in the order it needs to render them:</p>
<figure>
    <img loading="lazy" src="/building-trace-trees-with-recursive-ctes/healthy-search-context.png"
         alt="The healthy root trace rendered as a waterfall. Authenticate and checkout are children of root, fetch-user is nested beneath authenticate and highlighted with a Match label as the direct search result, and each row has a horizontal duration bar."/> <figcaption>
            <p>The healthy subtree preserves depth-first order while marking fetch-user as the direct search match.</p>
        </figcaption>
</figure>

<p>We&rsquo;ll build the query in stages: first the healthy tree, then search context, orphaned subtrees, and cycles.</p>
<h2 id="start-with-the-rows">Start with the rows</h2>
<p>The database identifies each span by <code>(trace_id, span_id)</code>.
The timing table below leaves out the trace ID they all share.
The database stores absolute timestamps, but here we&rsquo;re showing them as offsets from the start of the trace to make the timing easier to follow:</p>
<table>
	<thead>
			<tr>
					<th>name</th>
					<th style="text-align: right"><code>span_id</code></th>
					<th style="text-align: right"><code>parent_span_id</code></th>
					<th style="text-align: right">start offset</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>root</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right"><code>null</code></td>
					<td style="text-align: right">0 ms</td>
			</tr>
			<tr>
					<td>authenticate</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">100 ms</td>
			</tr>
			<tr>
					<td>checkout</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">120 ms</td>
			</tr>
			<tr>
					<td>fetch-user</td>
					<td style="text-align: right">4</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">150 ms</td>
			</tr>
	</tbody>
</table>
<p>It would be nice if we could <code>ORDER BY start_time</code> and call it a day.
Unfortunately, that leaves our tree looking like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">name                 span_id  parent_span_id  start offset
</span></span><span class="line"><span class="cl">root                       1            null          0 ms
</span></span><span class="line"><span class="cl">├── authenticate           2               1        100 ms
</span></span><span class="line"><span class="cl">├── checkout               3               1        120 ms
</span></span><span class="line"><span class="cl">│   └── fetch-user         4               2        150 ms
</span></span></code></pre></div><p>What we want is this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">name                 span_id  parent_span_id  start offset
</span></span><span class="line"><span class="cl">root                       1            null          0 ms
</span></span><span class="line"><span class="cl">├── authenticate           2               1        100 ms
</span></span><span class="line"><span class="cl">│   └── fetch-user         4               2        150 ms
</span></span><span class="line"><span class="cl">└── checkout               3               1        120 ms
</span></span></code></pre></div><p>The <a href="https://github.com/CtrlSpice/otel-desktop-viewer/blob/ffd204444eb8ab3c7910e37073f42622f83aee69/desktopexporter/internal/store/queries/ddl/tables/spans.sql">production spans table schema</a> is much wider, but we can simplify it to just four columns for this walkthrough:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">create</span><span class="w"> </span><span class="k">table</span><span class="w"> </span><span class="n">spans</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">trace_id</span><span class="w"> </span><span class="n">uuid</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">span_id</span><span class="w"> </span><span class="n">ubigint</span><span class="w"> </span><span class="k">not</span><span class="w"> </span><span class="k">null</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">parent_span_id</span><span class="w"> </span><span class="n">ubigint</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">start_time</span><span class="w"> </span><span class="nb">bigint</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>The SQL blocks from here on are snippets.
You can see the full query <a href="https://github.com/CtrlSpice/otel-desktop-viewer/blob/ffd204444eb8ab3c7910e37073f42622f83aee69/desktopexporter/internal/store/queries/spans/search_spans.sql">here</a>.</p>
<h2 id="prepare-the-walk">Prepare the walk</h2>
<p>The caller gives us something we hope is a <code>trace_id</code> as <code>?</code>.
<code>search_params</code> tries to cast it once.
Then <code>trace_spans</code> pulls out that trace and leaves the payload columns behind.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="n">search_params</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w"> </span><span class="n">try_cast</span><span class="p">(</span><span class="o">?</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">uuid</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">trace_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">trace_spans</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">materialized</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">trace_id</span><span class="p">,</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">span_id</span><span class="p">,</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">parent_span_id</span><span class="p">,</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">start_time</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">from</span><span class="w"> </span><span class="n">spans</span><span class="w"> </span><span class="n">s</span><span class="p">,</span><span class="w"> </span><span class="n">search_params</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">where</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">trace_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">search_params</span><span class="p">.</span><span class="n">trace_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><p>Using <code>try_cast</code> means bad input becomes <code>null</code>, matches nothing, and we&rsquo;re done.
<code>materialized</code> makes sure the more expensive operations target a dataset bounded by the size of the trace, not the full dataset.</p>
<p>The relation still carries the shared <code>trace_id</code> and absolute <code>start_time</code> values.
To keep the example readable, the table omits the repeated trace ID and displays each timestamp as an offset from the trace&rsquo;s earliest span:</p>
<table>
	<thead>
			<tr>
					<th style="text-align: right"><code>span_id</code></th>
					<th style="text-align: right"><code>parent_span_id</code></th>
					<th style="text-align: right">displayed start offset</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: right">1</td>
					<td style="text-align: right"><code>null</code></td>
					<td style="text-align: right">0 ms</td>
			</tr>
			<tr>
					<td style="text-align: right">2</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">100 ms</td>
			</tr>
			<tr>
					<td style="text-align: right">3</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">120 ms</td>
			</tr>
			<tr>
					<td style="text-align: right">4</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">150 ms</td>
			</tr>
	</tbody>
</table>
<h2 id="rank-the-rows">Rank the rows</h2>
<p>Before walking anything, the query assigns two positions.
<code>sibling_rank</code> records where a span sits among rows with the same parent.
<code>root_rank</code> gives every span a fixed position in the list, with spans that report no parent first.
Ideally we&rsquo;d have just one of those, but incomplete traces are common in practice.
More on that later.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="n">ranked</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">materialized</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="o">*</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">row_number</span><span class="p">()</span><span class="w"> </span><span class="n">over</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="n">partition</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">parent_span_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="k">order</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">start_time</span><span class="p">,</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">span_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sibling_rank</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">row_number</span><span class="p">()</span><span class="w"> </span><span class="n">over</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="k">order</span><span class="w"> </span><span class="k">by</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="k">case</span><span class="w"> </span><span class="k">when</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">parent_span_id</span><span class="w"> </span><span class="k">is</span><span class="w"> </span><span class="k">null</span><span class="w"> </span><span class="k">then</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">end</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="n">t</span><span class="p">.</span><span class="n">start_time</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="n">t</span><span class="p">.</span><span class="n">span_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">root_rank</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">from</span><span class="w"> </span><span class="n">trace_spans</span><span class="w"> </span><span class="n">t</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><p>Materializing <code>ranked</code> runs both windows once before the walk and lets every recursive level reuse their results.
For the running trace, those results are:</p>
<table>
	<thead>
			<tr>
					<th>name</th>
					<th style="text-align: right"><code>span_id</code></th>
					<th style="text-align: right"><code>sibling_rank</code></th>
					<th style="text-align: right"><code>root_rank</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>root</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
			</tr>
			<tr>
					<td>authenticate</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">2</td>
			</tr>
			<tr>
					<td>checkout</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">3</td>
			</tr>
			<tr>
					<td>fetch-user</td>
					<td style="text-align: right">4</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">4</td>
			</tr>
	</tbody>
</table>
<p>Only spans that become top-level rows (let&rsquo;s call them anchors) end up using <code>root_rank</code>.
Between <code>root_rank</code> and <code>sibling_rank</code>, we have all the information we need to build the tree!</p>
<h2 id="walk-the-tree">Walk the tree</h2>
<p>The complete statement begins with <code>with recursive</code>.
Within it, <code>spans_tree</code> has two parts: the anchor member seeds top-level rows, and the recursive member repeatedly adds their children.
The production anchor also accepts a span whose reported parent is missing.
That second condition does not affect this trace, and we will return to it after the healthy path.</p>
<p>The walk also builds a <code>sort_path</code> for each span.
We&rsquo;ll talk about it in the next section.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="n">spans_tree</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">-- Anchor member: seed roots and spans whose parent is missing.
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">trace_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">span_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">parent_span_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">start_time</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="mi">0</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">depth</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nb">array</span><span class="p">[</span><span class="n">r</span><span class="p">.</span><span class="n">root_rank</span><span class="p">]</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sort_path</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">from</span><span class="w"> </span><span class="n">ranked</span><span class="w"> </span><span class="n">r</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">where</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">parent_span_id</span><span class="w"> </span><span class="k">is</span><span class="w"> </span><span class="k">null</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">or</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">parent_span_id</span><span class="w"> </span><span class="k">not</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="p">(</span><span class="k">select</span><span class="w"> </span><span class="n">span_id</span><span class="w"> </span><span class="k">from</span><span class="w"> </span><span class="n">trace_spans</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">union</span><span class="w"> </span><span class="k">all</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">-- Recursive member: add the children of the previous iteration.
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">trace_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">span_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">parent_span_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">r</span><span class="p">.</span><span class="n">start_time</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">st</span><span class="p">.</span><span class="n">depth</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">st</span><span class="p">.</span><span class="n">sort_path</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">array</span><span class="p">[</span><span class="n">r</span><span class="p">.</span><span class="n">sibling_rank</span><span class="p">]</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">sort_path</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">from</span><span class="w"> </span><span class="n">ranked</span><span class="w"> </span><span class="n">r</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">join</span><span class="w"> </span><span class="n">spans_tree</span><span class="w"> </span><span class="n">st</span><span class="w"> </span><span class="k">on</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="n">parent_span_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">st</span><span class="p">.</span><span class="n">span_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><p>The anchor places <code>root</code> as a top-level row with a one-item path.
On each iteration, DuckDB reads the rows produced by the previous iteration, finds their children, and appends those rows to the result.
The walk stops when an iteration finds no more children.</p>
<p>Because <code>ranked</code> contains only the requested trace, the recursive self-join can match on <code>span_id</code> alone.
Later joins back to unrestricted tables use both <code>trace_id</code> and <code>span_id</code> to preserve that scope.</p>
<p>Ignoring output order for the moment, <code>spans_tree</code> has attached a depth and path to every row:</p>
<table>
	<thead>
			<tr>
					<th>name</th>
					<th style="text-align: right"><code>span_id</code></th>
					<th style="text-align: right"><code>parent_span_id</code></th>
					<th style="text-align: right"><code>depth</code></th>
					<th><code>sort_path</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>root</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right"><code>null</code></td>
					<td style="text-align: right">0</td>
					<td><code>[1]</code></td>
			</tr>
			<tr>
					<td>authenticate</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td><code>[1, 1]</code></td>
			</tr>
			<tr>
					<td>checkout</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">1</td>
					<td style="text-align: right">1</td>
					<td><code>[1, 2]</code></td>
			</tr>
			<tr>
					<td>fetch-user</td>
					<td style="text-align: right">4</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">2</td>
					<td><code>[1, 1, 1]</code></td>
			</tr>
	</tbody>
</table>
<h2 id="sort-paths">Sort paths</h2>
<p>Because start time alone cannot keep a subtree together, the query needs one value whose ordinary sort order produces depth-first traversal.
<code>sort_path</code> records the sibling choice made at each level from the root to a span: the root starts at <code>[1]</code>, its first child appends <code>1</code>, and that child&rsquo;s first child appends another <code>1</code>.
<code>checkout</code> is the root&rsquo;s second child, so its path is <code>[1, 2]</code> even though it started before <code>fetch-user</code>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">root                 [1]
</span></span><span class="line"><span class="cl">├── authenticate     [1, 1]
</span></span><span class="line"><span class="cl">│   └── fetch-user   [1, 1, 1]
</span></span><span class="line"><span class="cl">└── checkout         [1, 2]
</span></span></code></pre></div><p><a href="https://duckdb.org/docs/current/sql/data_types/list.html#comparison-and-ordering">DuckDB compares lists lexicographically</a>.
Each prefix sorts before the longer paths below it, while sibling ranks keep neighbouring subtrees in start-time order.
That puts <code>[1, 1, 1]</code> before <code>[1, 2]</code>, producing the display order we wanted.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">select</span><span class="w"> </span><span class="n">trace_id</span><span class="p">,</span><span class="w"> </span><span class="n">span_id</span><span class="p">,</span><span class="w"> </span><span class="n">parent_span_id</span><span class="p">,</span><span class="w"> </span><span class="n">start_time</span><span class="p">,</span><span class="w"> </span><span class="n">depth</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">from</span><span class="w"> </span><span class="n">spans_tree</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">order</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="n">sort_path</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>When spans have the same <code>start_time</code>, we use <code>span_id</code> as a tie-breaker to keep their order deterministic.
The production query carries this structure into the payload and JSON stages below.</p>
<h2 id="add-the-payload">Add the payload</h2>
<p>The recursive rows carry only IDs, timing, depth, and <code>sort_path</code>, which keeps each row narrow while DuckDB copies it through the walk.
Once recursion finishes, <code>tree</code> joins the kitchen sink back in.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="n">tree</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">materialized</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">st</span><span class="p">.</span><span class="n">depth</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">st</span><span class="p">.</span><span class="n">sort_path</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">trace_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">span_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">parent_span_id</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">start_time</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">end_time</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">status_code</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">s</span><span class="p">.</span><span class="n">attribute_ids</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">from</span><span class="w"> </span><span class="n">spans_tree</span><span class="w"> </span><span class="n">st</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">join</span><span class="w"> </span><span class="n">spans</span><span class="w"> </span><span class="n">s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">on</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">trace_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">st</span><span class="p">.</span><span class="n">trace_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">and</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">span_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">st</span><span class="p">.</span><span class="n">span_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><p><code>tree</code> is the boundary between traversal and response shaping.
New response fields join here, after DuckDB has finished copying rows through recursion.</p>
<h2 id="add-search">Add search</h2>
<p>The waterfall still needs every span when only a few match.
Removing non-matches would discard ancestor context and change the depths and paths the query builds.</p>
<p>For a simple name predicate, <code>matched</code> could be calculated directly in <code>tree</code>.
The production search is kept as a separate relation so matching cannot change the recursive input, and more involved predicates do not widen the walk.
For a name search, that relation has this shape:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="n">matched_spans</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">select</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">span_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">from</span><span class="w"> </span><span class="n">spans</span><span class="w"> </span><span class="n">s</span><span class="p">,</span><span class="w"> </span><span class="n">search_params</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">where</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">trace_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">search_params</span><span class="p">.</span><span class="n">trace_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="k">and</span><span class="w"> </span><span class="n">s</span><span class="p">.</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><p>In the final projection, <code>tree</code> is aliased as <code>ts</code> and left joined to those IDs.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">from</span><span class="w"> </span><span class="n">tree</span><span class="w"> </span><span class="n">ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">left</span><span class="w"> </span><span class="k">join</span><span class="w"> </span><span class="n">matched_spans</span><span class="w"> </span><span class="n">ms</span><span class="w"> </span><span class="k">on</span><span class="w"> </span><span class="n">ts</span><span class="p">.</span><span class="n">span_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">ms</span><span class="p">.</span><span class="n">span_id</span><span class="w">
</span></span></span></code></pre></div><p>The presence of a match becomes the flag used by the interface.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="s1">&#39;matched&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="k">when</span><span class="w"> </span><span class="n">ms</span><span class="p">.</span><span class="n">span_id</span><span class="w"> </span><span class="k">is</span><span class="w"> </span><span class="k">not</span><span class="w"> </span><span class="k">null</span><span class="w"> </span><span class="k">then</span><span class="w"> </span><span class="k">true</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">end</span><span class="w">
</span></span></span></code></pre></div><p>The query returns the complete ordered trace and marks direct matches.
The front end can keep paths to matches open and collapse unrelated subtrees without changing the display topology.</p>
<p>Stripped of payload fields and wrapper objects, a search for <code>fetch-user</code> now produces rows shaped like these:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;span_id&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;root&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="nt">&#34;matched&#34;</span><span class="p">:</span> <span class="kc">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;span_id&#34;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;authenticate&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;matched&#34;</span><span class="p">:</span> <span class="kc">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;span_id&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;fetch-user&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="nt">&#34;matched&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nt">&#34;span_id&#34;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;checkout&#34;</span><span class="p">,</span> <span class="nt">&#34;depth&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;matched&#34;</span><span class="p">:</span> <span class="kc">false</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span></code></pre></div><h2 id="render-the-healthy-trace">Render the healthy trace</h2>
<p>The final JSON macro turns absolute timestamps into the position and width the waterfall needs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="s1">&#39;start&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">.</span><span class="n">start_time</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">trace_start_ns</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;dur&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">ts</span><span class="p">.</span><span class="n">end_time</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">ts</span><span class="p">.</span><span class="n">start_time</span><span class="w">
</span></span></span></code></pre></div><p>DuckDB does the recursive work once and returns each span with its display order, depth, and timing already attached.
This has a number of benefits!</p>
<ul>
<li>The backend can stay simple.
It just passes the result on to the frontend.</li>
<li>The browser does not run another recursive traversal to decide order or depth.
Expensive computation stays in the database, and we avoid jank.</li>
<li>The browser uses the same ordered list for rendering, search reveal, and keyboard navigation.</li>
<li>The browser&rsquo;s virtual list mounts only the rows in and around the viewport, rather than every span in the trace, which is how the interface stays snappy, even with thousands of spans (or at least snap-adjacent).</li>
</ul>
<h2 id="when-traces-misbehave">When traces misbehave</h2>
<p>In a complete, valid trace, every non-root span has one parent, and repeatedly following its reported <code>parent_span_id</code> eventually reaches a root.
However, otel-desktop-viewer is meant to be used in development, where we might receive data that&rsquo;s&hellip; not quite right.
We need to handle malformed telemetry gracefully and gently let the user know that they might want to fix it.</p>
<p>The screenshots below use the same healthy relationships, plus these rows:</p>
<table>
	<thead>
			<tr>
					<th>name</th>
					<th style="text-align: right"><code>span_id</code></th>
					<th style="text-align: right"><code>parent_span_id</code></th>
					<th style="text-align: right">start offset</th>
					<th>condition</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>orphan-root</td>
					<td style="text-align: right">5</td>
					<td style="text-align: right">255 (missing)</td>
					<td style="text-align: right">50 ms</td>
					<td>parent absent</td>
			</tr>
			<tr>
					<td>orphan-child</td>
					<td style="text-align: right">6</td>
					<td style="text-align: right">5</td>
					<td style="text-align: right">75 ms</td>
					<td>child of the promoted orphan</td>
			</tr>
			<tr>
					<td>early-off-cycle-child</td>
					<td style="text-align: right">7</td>
					<td style="text-align: right">9</td>
					<td style="text-align: right">10 ms</td>
					<td>descendant of the cyclic component</td>
			</tr>
			<tr>
					<td>cycle-a</td>
					<td style="text-align: right">9</td>
					<td style="text-align: right">8</td>
					<td style="text-align: right">500 ms</td>
					<td>opens the cycle</td>
			</tr>
			<tr>
					<td>cycle-b</td>
					<td style="text-align: right">8</td>
					<td style="text-align: right">9</td>
					<td style="text-align: right">520 ms</td>
					<td>closes the cycle</td>
			</tr>
	</tbody>
</table>
<h3 id="orphans">Orphans</h3>
<figure class="orphan-tree-swing">
    <img loading="lazy" src="/building-trace-trees-with-recursive-ctes/orphan-tree-swing.gif"
         alt="A monochrome sketch of a fruit tree with a swing hanging from branches on opposite sides, forcing the ropes through a suspended box around the trunk. Handwritten text reads, &#39;Looks perfectly logical to me.&#39;"/> <figcaption>
            <p>A swing, a simple swing. - Terry Pratchett, Soul Music. Art by <a href="https://imgur.com/gallery/SctyOQf">AprilDylan</a>.</p>
        </figcaption>
</figure>

<p>The anchor condition we deferred earlier promotes a span whose reported parent is absent to a top-level row.
Promotion changes only its place in the display tree.
The stored <code>parent_span_id</code> remains faithful to the telemetry.
The normal walk then continues through its descendants.</p>
<p><code>root_rank</code> is calculated before the anchor filter, so non-anchor spans still consume numbers.
In this fixture, <code>early-off-cycle-child</code> consumes rank 2 even though it is not an anchor, so <code>orphan-root</code> begins at <code>[3]</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">orphan-root          [3]
</span></span><span class="line"><span class="cl">└── orphan-child     [3, 1]
</span></span></code></pre></div><p>The gap does not change its order relative to the healthy root.
<code>span_id</code> is <code>not null</code>, so the anchor&rsquo;s <code>not in</code> check is safe here.</p>
<h3 id="cycles">Cycles</h3>
<p>Or maybe you vibe coded too close to the sun and your span is its own grandfather:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">cycle-a -&gt; cycle-b -&gt; cycle-a
</span></span></code></pre></div><p>Neither span offers the normal walk a top-level starting point, so it reaches neither.</p>
<p>The normal query reports that gap as <code>count(trace_spans) - count(tree)</code>, returned as a separate integer beside the trace JSON.
When the count is nonzero, the backend reruns the whole trace with a <a href="https://github.com/CtrlSpice/otel-desktop-viewer/blob/ffd204444eb8ab3c7910e37073f42622f83aee69/desktopexporter/internal/store/queries/spans/salvage_spans.sql#L75-L150">salvage query</a> rather than merging a fragment into the first response.
If salvage itself fails, the backend returns the shorter normal result rather than replacing a usable partial waterfall with an error page.</p>
<p>Every unreached span gets an <code>entry_rank</code> from its <code>start_time, span_id</code> order, then seeds a candidate walk.
Each candidate tracks the IDs it has visited and stops before repeating one.
Because a span can appear in several candidates, deduplication keeps the placement with the lowest <code>entry_rank</code>, then the shallowest depth.</p>
<p>In this fixture, <code>early-off-cycle-child</code> starts first and keeps its <code>entry_rank</code> 1 placement as a separate top-level row.
Of the actual cycle entries, <code>cycle-a</code> ranks before <code>cycle-b</code>, so its candidate wins both spans:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">cycle-a  cyclePoint
</span></span><span class="line"><span class="cl">└── cycle-b
</span></span></code></pre></div><p>The marker means that <code>cycle-a</code>&rsquo;s reported parent appears below it in the retained candidate chain.
The earlier off-cycle child keeps its salvage-warning triangle but not the <code>cyclePoint</code> biohazard because its reported parent does not appear in its one-row chain.</p>
<p>Carrying ancestry and a complete relative path makes each recursive row wider, so that work stays in the fallback and runs only when the normal query leaves spans behind.</p>
<figure>
    <img loading="lazy" src="/building-trace-trees-with-recursive-ctes/search-context.png"
         alt="A trace waterfall filtered to fetch-user. The matching fetch-user row is highlighted and labelled Match beneath root and authenticate, while the descendants of unrelated orphan and cycle branches are collapsed."/> <figcaption>
            <p>A search for fetch-user keeps its path open and folds unrelated subtrees.</p>
        </figcaption>
</figure>

<figure>
    <img loading="lazy" src="/building-trace-trees-with-recursive-ctes/cycle-recovery-waterfall.png"
         alt="A recovered trace waterfall showing the healthy root subtree, an orphan promoted to the top level, warning triangles on early-off-cycle-child and cycle-b, and a biohazard cycle-point marker on the selected cycle-a row."/> <figcaption>
            <p>Warning triangles mark salvaged rows. The biohazard marks the retained cycle cut at cycle-a.</p>
        </figcaption>
</figure>

<figure>
    <img loading="lazy" src="/building-trace-trees-with-recursive-ctes/cycle-recovery-detail.png"
         alt="The detail panel for cycle-a shows its span ID as 9 and its reported parent span ID as 8, which belongs to cycle-b below it in the recovered waterfall."/> <figcaption>
            <p>The detail panel preserves the reported parent and span IDs behind the cycle annotation.</p>
        </figcaption>
</figure>

<h2 id="whew">Whew</h2>
<p>That was a lot, but we did it, y&rsquo;all.
The <a href="https://github.com/CtrlSpice/otel-desktop-viewer/blob/ffd204444eb8ab3c7910e37073f42622f83aee69/desktopexporter/internal/store/queries/spans/search_spans.sql">full query</a> handles the healthy path, search context, orphans, and cycles, then hands the front end one ordered list.
We have successfully made it DuckDB&rsquo;s problem!</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>I&rsquo;m not allowed near Evocation or <a href="https://strangehorizons.com/wordpress/non-fiction/articles/installing-linux-on-a-dead-badger-users-notes/">Technomancy</a> since incident [REDACTED].&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item></channel></rss>