<?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: Borys Generalov</title>
    <description>The latest articles on DEV Community by Borys Generalov (@bgener).</description>
    <link>https://dev.to/bgener</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1369409%2Fd1f346cb-9663-41ed-bf97-ca75282e5d52.png</url>
      <title>DEV Community: Borys Generalov</title>
      <link>https://dev.to/bgener</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bgener"/>
    <language>en</language>
    <item>
      <title>GitHub Agentic Workflows: Building Self-Healing CI for .NET</title>
      <dc:creator>Borys Generalov</dc:creator>
      <pubDate>Thu, 21 May 2026 16:10:11 +0000</pubDate>
      <link>https://dev.to/bgener/github-agentic-workflows-building-self-healing-ci-for-net-5f7f</link>
      <guid>https://dev.to/bgener/github-agentic-workflows-building-self-healing-ci-for-net-5f7f</guid>
      <description>&lt;h2&gt;
  
  
  Agentic Platform Engineering: Self-Healing CI/CD Pipelines
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Demo Repository:&lt;/strong&gt; Check the &lt;a href="https://github.com/bgener/demo-ai-github-pipelines" rel="noopener noreferrer"&gt;complete project on GitHub&lt;/a&gt; to see the full setup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My CI failures are usually not dramatic. But they are still annoying.&lt;/p&gt;

&lt;p&gt;A test breaks with a &lt;code&gt;NullReferenceException&lt;/code&gt;. A Helm chart release failed. I open the logs, trace the problem, fix a tiny mistake, push, and wait for CI again. That is a lot of delay for bugs that are often small.&lt;/p&gt;

&lt;p&gt;So I built a workflow for that exact loop. When CI fails, a GitHub Agentic Workflow reads the logs and uploaded artifacts, traces the root cause, and asks for a draft pull request with the fix. I still review it. I still merge it. The agent does the investigation work that normally takes the first 15 minutes.&lt;/p&gt;

&lt;p&gt;In this article, I will show you how I built that setup in a standard .NET project, how I fed the agent the evidence it needed, and what happened when I tested it with two deliberate bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Are GitHub Agentic Workflows?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Getting started:&lt;/strong&gt; Install the CLI and set up your first workflow by following the &lt;a href="https://github.github.com/gh-aw/setup/quick-start/" rel="noopener noreferrer"&gt;official quick start guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GitHub Agentic Workflows let you define automation in Markdown with YAML frontmatter. That sounds really simple. The YAML part tells GitHub when the workflow runs, which permissions it gets, and which safe write actions it may request. The Markdown body tells the agent what job to do.&lt;/p&gt;

&lt;p&gt;Here is a tiny example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-all&lt;/span&gt;
&lt;span class="na"&gt;safe-outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;add-comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Issue Clarifier&lt;/span&gt;

Analyze the current issue and ask for additional details if the issue is unclear.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You compile that file with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh aw compile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a &lt;code&gt;.lock.yml&lt;/code&gt; file that GitHub Actions can execute. The Markdown file is the source you maintain. The compiled workflow is what runs in CI.&lt;/p&gt;

&lt;p&gt;Here, I am not using agentic workflows for summaries or changelogs. Those are straightforward to automate. I care about practical use cases where a failure is easy to address, yet if the CI build fails, someone still has to dig through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting Up the Project
&lt;/h2&gt;

&lt;p&gt;I used a plain .NET 10 Web API and an xUnit test project. No custom starter kit. Just the templates you already know.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaffold the Project
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new sln &lt;span class="nt"&gt;-n&lt;/span&gt; DemoAiPipelines
dotnet new webapi &lt;span class="nt"&gt;-n&lt;/span&gt; OrdersApi &lt;span class="nt"&gt;-o&lt;/span&gt; src/OrdersApi
dotnet new xunit &lt;span class="nt"&gt;-n&lt;/span&gt; OrdersApi.Tests &lt;span class="nt"&gt;-o&lt;/span&gt; tests/OrdersApi.Tests

dotnet sln add src/OrdersApi/OrdersApi.csproj
dotnet sln add tests/OrdersApi.Tests/OrdersApi.Tests.csproj

&lt;span class="nb"&gt;cd &lt;/span&gt;tests/OrdersApi.Tests
dotnet add reference ../../src/OrdersApi/OrdersApi.csproj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I added two deliberate bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug One: Guest Checkout Crash
&lt;/h3&gt;

&lt;p&gt;This service throws when &lt;code&gt;Customer&lt;/code&gt; is null:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="nf"&gt;CalculateDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// BUG: throws NullReferenceException when Customer is null&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoyaltyTier&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"gold"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0.15m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"silver"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0.10m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0.05m&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Total&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the test that exposes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;CalculateDiscount_GuestCheckout_ReturnsZero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_sut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CalculateDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// crash here&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bug Two: Wrong Port in Helm
&lt;/h3&gt;

&lt;p&gt;The app listens on &lt;code&gt;8080&lt;/code&gt;, but the chart still points at &lt;code&gt;80&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-api&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is enough to make Kubernetes restart the pod forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capture the Evidence
&lt;/h3&gt;

&lt;p&gt;If you want an agent to investigate failures, you need to upload the same logs you would need as a human. For test failures, that means the test output. For deploy failures, that means enough cluster state to explain why the pod never became healthy.&lt;/p&gt;

&lt;p&gt;Here is the CI workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet test --logger "trx;LogFileName=results.trx" 2&amp;gt;&amp;amp;1 | tee test-output.txt&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload test evidence&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test-results&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/test-output.txt"&lt;/span&gt;

  &lt;span class="na"&gt;deploy-to-kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create KinD cluster&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helm/kind-action@v1.12.0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy with Helm&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helm&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;helm upgrade --install orders-api ./deploy/helm \&lt;/span&gt;
            &lt;span class="s"&gt;--wait --timeout 2m 2&amp;gt;&amp;amp;1 | tee helm-output.txt&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Capture Kubernetes state&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.helm.outcome == 'failure'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;kubectl get pods -o wide &amp;gt; k8s-debug.txt&lt;/span&gt;
          &lt;span class="s"&gt;kubectl describe pods &amp;gt;&amp;gt; k8s-debug.txt&lt;/span&gt;
          &lt;span class="s"&gt;kubectl logs -l app=orders-api --tail=50 &amp;gt;&amp;gt; k8s-debug.txt&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload deploy evidence&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-results&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-debug.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Writing the Self-Healing Workflow
&lt;/h2&gt;

&lt;p&gt;Now we can define the workflow that reacts to a failed CI run.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/self-heal.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;copilot&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&lt;/span&gt; &lt;span class="c1"&gt;# defaults to latest&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-5&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CI"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="na"&gt;safe-outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create-pull-request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;title-prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fix:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ai-fix&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;self-healing&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Self-Healing: Fix Failed CI&lt;/span&gt;

You are a .NET and DevOps engineer. A CI run has just failed.

&lt;span class="gu"&gt;## Your Mission&lt;/span&gt;

Analyze the failure, find the root cause, and submit a fix as a
pull request.

&lt;span class="gu"&gt;## Step-by-Step Instructions&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Check which job failed: &lt;span class="sb"&gt;`build-and-test`&lt;/span&gt; or &lt;span class="sb"&gt;`deploy-to-kind`&lt;/span&gt;.
&lt;span class="p"&gt;2.&lt;/span&gt; Download the relevant artifact (&lt;span class="sb"&gt;`test-results`&lt;/span&gt; or &lt;span class="sb"&gt;`deploy-results`&lt;/span&gt;).
&lt;span class="p"&gt;3.&lt;/span&gt; Read the logs to identify the root cause.
&lt;span class="p"&gt;4.&lt;/span&gt; For test failures: find the exception and fix the source code.
&lt;span class="p"&gt;5.&lt;/span&gt; For deploy failures: read &lt;span class="sb"&gt;`k8s-debug.txt`&lt;/span&gt; to trace the issue.
&lt;span class="p"&gt;   -&lt;/span&gt; Cross-reference with the Dockerfile and Helm chart.
&lt;span class="p"&gt;6.&lt;/span&gt; Open a PR explaining what went wrong and why the fix is correct.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You do not need to overdo the prompt. You do need to tell the agent where the failure lives, which artifact to read, and what kind of output you want back.&lt;/p&gt;




&lt;h2&gt;
  
  
  Watching It Fix Real Failures
&lt;/h2&gt;

&lt;p&gt;The normal CI workflow ran first. That workflow built the code, ran tests, tried the Helm deployment, and uploaded evidence whether it passed or failed. After that finished, GitHub triggered the compiled agentic workflow through &lt;code&gt;workflow_run&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the order looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the regular &lt;code&gt;CI&lt;/code&gt; workflow ran&lt;/li&gt;
&lt;li&gt;one job failed: test or deploy&lt;/li&gt;
&lt;li&gt;CI uploaded the relevant artifact&lt;/li&gt;
&lt;li&gt;the compiled self-heal workflow started&lt;/li&gt;
&lt;li&gt;the agent downloaded the artifact and investigated&lt;/li&gt;
&lt;li&gt;the agent proposed a draft PR&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means the self-healing workflow only woke up after the normal pipeline had already failed and produced evidence. It was not polling. It was not scanning on a schedule. It reacted to a failed run automatically.&lt;/p&gt;

&lt;p&gt;For the test failure, the agent downloaded the &lt;code&gt;test-results&lt;/code&gt; artifact, found the &lt;code&gt;NullReferenceException&lt;/code&gt;, followed the stack trace into &lt;code&gt;OrderService.cs&lt;/code&gt;, and proposed the missing null check.&lt;/p&gt;

&lt;p&gt;For the deploy failure, it downloaded the deployment evidence, read &lt;code&gt;k8s-debug.txt&lt;/code&gt;, saw that the app was listening on &lt;code&gt;8080&lt;/code&gt; while the probe was still hitting &lt;code&gt;80&lt;/code&gt;, and changed the Helm config to match.&lt;/p&gt;

&lt;p&gt;In both cases the result was a draft PR, not a silent commit to the branch.&lt;/p&gt;

&lt;p&gt;That was important for me because I wanted to see the exact diff, the explanation, and the reasoning path. I was not trying to hide the process. I wanted the same review surface I would expect from a teammate.&lt;/p&gt;

&lt;p&gt;This also made testing the idea straightforward. Break the pipeline on purpose, let the normal CI fail, and watch whether the follow-up workflow can read the evidence and get back to the right fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Cost of Self-Healing
&lt;/h2&gt;

&lt;p&gt;The extra cost starts only after CI fails and the self-heal workflow wakes up. You pay for the agent run, the model tokens used to read logs and repo files, and whatever artifact storage and transfer that investigation needs.&lt;/p&gt;

&lt;p&gt;So the real way to think about it is cost per failed run. If failures are rare and the artifacts are small, the cost stays low. If builds fail often and every failure uploads huge logs, the bill grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Rollout Plan
&lt;/h2&gt;

&lt;p&gt;If I were rolling this out for a real team, I would keep the scope narrow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;run only after failed CI workflows&lt;/li&gt;
&lt;li&gt;restrict it to one or two common failure types&lt;/li&gt;
&lt;li&gt;require uploaded evidence before the workflow can act&lt;/li&gt;
&lt;li&gt;allow only draft pull requests as output&lt;/li&gt;
&lt;li&gt;review every proposed diff manually&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That keeps the workflow predictable. It also gives you a clean way to measure the return on cost. I would track three basic numbers from day one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;how many failed runs triggered the workflow&lt;/li&gt;
&lt;li&gt;how many proposed PRs were actually correct&lt;/li&gt;
&lt;li&gt;how much engineer time those investigations would normally have taken&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the workflow costs a few dollars but saves hours of senior engineering time on repeatable failures, the tradeoff is obvious. If it produces noisy PRs that nobody merges, the token bill is a waste of money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Self-Healing Gets More Interesting
&lt;/h2&gt;

&lt;p&gt;I would not jump straight to "AI fixes everything." I would expand the triggers one by one.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;after deployment, scan pod logs for restart loops or obvious startup exceptions&lt;/li&gt;
&lt;li&gt;after a health check job, inspect the logs if the app never became ready&lt;/li&gt;
&lt;li&gt;after a scheduled smoke test, investigate if an endpoint starts failing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where self-healing gets interesting. Not a magic system that pushes to production on its own, but a continuous investigator that notices a broken deployment, reads the evidence, and hands you a draft PR.&lt;/p&gt;

&lt;p&gt;I still would not let it merge for me. But I would absolutely let it do the boring first pass on failures.&lt;/p&gt;

&lt;p&gt;If you want to try the full setup yourself, the demo repo and workflow files are here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Demo Repository:&lt;/strong&gt;&lt;br&gt;
  &lt;a href="https://github.com/bgener/demo-ai-github-pipelines" rel="noopener noreferrer"&gt;github.com/bgener/demo-ai-github-pipelines&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is part of &lt;strong&gt;The Modern DevEx Stack&lt;/strong&gt; series. The next post looks at using MegaLinter in a polyglot repo without turning every pull request into a waiting game.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>githubcopilot</category>
      <category>devex</category>
    </item>
    <item>
      <title>Build an AI-Powered Developer Portal with Backstage and .NET</title>
      <dc:creator>Borys Generalov</dc:creator>
      <pubDate>Thu, 21 May 2026 15:58:15 +0000</pubDate>
      <link>https://dev.to/bgener/build-an-ai-powered-developer-portal-with-backstage-and-net-300j</link>
      <guid>https://dev.to/bgener/build-an-ai-powered-developer-portal-with-backstage-and-net-300j</guid>
      <description>&lt;h1&gt;
  
  
  Build an AI-Powered Developer Portal with Backstage and .NET
&lt;/h1&gt;

&lt;p&gt;Want to apply AI, not just read about it? Most tutorials stop at a "Hello World" chatbot. We are going to build something that actually solves a common engineering headache: stale documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;p&gt;This guide is for platform engineers and .NET developers who need to organize a growing software landscape without forcing teams to manually write YAML files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you will build
&lt;/h2&gt;

&lt;p&gt;You will build a &lt;strong&gt;dynamic developer portal&lt;/strong&gt; using &lt;a href="https://backstage.io/" rel="noopener noreferrer"&gt;Backstage&lt;/a&gt; that automatically populates its service catalog. We will use a .NET CLI tool to scan source code and use local AI (&lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;) to generate summaries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source repo:&lt;/strong&gt; &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;demo-backstage-catalog-generator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraint:&lt;/strong&gt; We use local inference only. No source code ever leaves your machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you ever needed to update a service, but forgot what it does? Or spent time trying to understand code you have not touched in months? We usually solve this with a README.md that nobody updates, or a wiki that rots.&lt;/p&gt;

&lt;p&gt;An Internal Developer Portal (IDP) solves this by making the software landscape visible, but only if the data is fresh. Automation is the only way to avoid the "stale metadata" trap.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Want to skip ahead?&lt;/strong&gt; Check out the &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;complete working demo on&lt;br&gt;
  GitHub&lt;/a&gt; with all&lt;br&gt;
  the code ready to run!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before we start, make sure you have the following installed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dotnet.microsoft.com/en-us/download" rel="noopener noreferrer"&gt;.NET SDK 8+&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; (includes &lt;code&gt;npx&lt;/code&gt; and &lt;code&gt;yarn&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; with the &lt;code&gt;llama3:8b&lt;/code&gt; model pulled&lt;/li&gt;
&lt;li&gt;A GitHub account (for hosting and deployment)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does an Internal Developer Portal Matter?
&lt;/h2&gt;

&lt;p&gt;The term "Internal Developer Portal" (IDP) can be a little misleading, since it sounds like a tool exclusively for developers only. In reality, it functions as an &lt;strong&gt;internal organizational portal focused entirely on your software portfolio&lt;/strong&gt;. Unlike a general-purpose SharePoint site where everything is dumped in one place and nothing is easy to find, an IDP is deliberately narrow in scope. It covers your software landscape and nothing else, which is exactly what makes it powerful.&lt;/p&gt;

&lt;p&gt;An IDP becomes the &lt;strong&gt;single source of truth&lt;/strong&gt; for your engineering organization. It answers critical questions across every role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engineers:&lt;/strong&gt; Which services exist? Who owns them? What do they do? What are the APIs and how do I call them?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team leads and architects:&lt;/strong&gt; What is the team composition? Which squad owns which set of services? What architectural decisions have been made and why?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New joiners:&lt;/strong&gt; How do I get up to speed on a codebase I have never seen before?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform and operations teams:&lt;/strong&gt; What is running in production, who is responsible, and what is the lifecycle status?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beyond just listing services, a mature IDP centralizes &lt;strong&gt;Architecture Decision Records (ADRs)&lt;/strong&gt;, arguably one of its most valuable features. ADRs capture &lt;em&gt;why&lt;/em&gt; a decision was made, not just what was decided. Without a central place to surface them, they rot in forgotten wiki pages or git repositories that no one thinks to check.&lt;/p&gt;

&lt;p&gt;The challenge is keeping all of this populated and accurate. If you rely on engineers to manually maintain metadata YAML files, the data grows stale within weeks. Automation is the only sustainable path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formulating the Architecture
&lt;/h2&gt;

&lt;p&gt;Here's the plan to extract metadata from source code and present it visually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backstage:&lt;/strong&gt; the UI layer where engineers browse and discover services, APIs, documentation, and team ownership&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.NET Core:&lt;/strong&gt; a CLI tool that scans project folders, extracts metadata, and generates Backstage-compatible YAML&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama:&lt;/strong&gt; runs AI inference locally, so your source code never leaves your machine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static hosting:&lt;/strong&gt; deploy to Netlify, Azure Static Web Apps, or any provider of your choice&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Why Ollama?&lt;/em&gt; Because it runs locally and you do not want to expose your code&lt;br&gt;
  to the AI agents over the public internet. You do not know what and how they&lt;br&gt;
  use it for, and it does not feel safe. If your employer finds out, you're&lt;br&gt;
  done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Setting Up the Project Infrastructure
&lt;/h2&gt;

&lt;p&gt;You can either follow along and build everything from scratch, or clone the &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;demo repository&lt;/a&gt; to get started immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To clone the demo:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/bgener/demo-backstage-catalog-generator.git
&lt;span class="nb"&gt;cd &lt;/span&gt;demo-backstage-catalog-generator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;To build from scratch&lt;/strong&gt;, start by downloading and running the LLM model we will use in this guide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull llama3:8b
ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt;&lt;br&gt;
We use &lt;code&gt;llama3:8b&lt;/code&gt; specifically. It is significantly faster for local&lt;br&gt;
  inference than the full-size model and produces more consistent, concise&lt;br&gt;
  output for our use case. If you have a powerful GPU, feel free to use &lt;code&gt;llama3&lt;/code&gt;&lt;br&gt;
  instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next, scaffold the baseline .NET services. We’ll create one Web API and one MVC project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;Backstage-Dev-Portal
&lt;span class="nb"&gt;cd &lt;/span&gt;Backstage-Dev-Portal

dotnet new webapi &lt;span class="nt"&gt;-n&lt;/span&gt; ServiceA
dotnet new mvc &lt;span class="nt"&gt;-n&lt;/span&gt; ServiceB

dotnet new sln &lt;span class="nt"&gt;-n&lt;/span&gt; Backstage-Dev-Portal
dotnet sln add ServiceA/ServiceA.csproj
dotnet sln add ServiceB/ServiceB.csproj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can replace the default controllers with real logic later. These raw services represent the uncataloged microservices in your organization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Smart Catalog Generator in .NET
&lt;/h2&gt;

&lt;p&gt;We will build a .NET CLI tool using &lt;a href="https://github.com/awaescher/OllamaSharp" rel="noopener noreferrer"&gt;OllamaSharp&lt;/a&gt;. It scans each project, sends relevant files to the local AI model, and generates a single &lt;code&gt;catalog-info.yaml&lt;/code&gt; file containing all services, ready for Backstage to consume.&lt;/p&gt;

&lt;p&gt;Create the tool and add the required package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new console &lt;span class="nt"&gt;-n&lt;/span&gt; ProjectSummarizer
&lt;span class="nb"&gt;cd &lt;/span&gt;ProjectSummarizer
dotnet add package OllamaSharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of sending every file to the AI, we take a smarter approach to avoid token limits and save compute time. We will send only &lt;code&gt;*.csproj&lt;/code&gt;, &lt;code&gt;Program.cs&lt;/code&gt;, and the folder structure. This is all the context the AI needs to understand the project structure and purpose.&lt;/p&gt;

&lt;p&gt;Replace &lt;code&gt;Program.cs&lt;/code&gt; with the implementation below. The full version is in the &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;demo repository&lt;/a&gt;. Here we focus on the key parts.&lt;/p&gt;

&lt;p&gt;First, set up the Ollama client and configure the system prompt. This is the most fragile part of the chain: the system prompt has to force the model into a YAML-safe format without it hallucinating markdown backticks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ollamaApiClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OllamaApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:11434"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;SelectedModel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"llama3:8b"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ollamaApiClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s"&gt;"You are a technical documentation assistant. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"You produce concise, YAML-safe summaries of .NET projects. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Output only plain text, no markdown, no bullet points, no quotes, no colons, no newlines."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of sending every file to the AI, we only send &lt;code&gt;*.csproj&lt;/code&gt;, &lt;code&gt;Program.cs&lt;/code&gt;, and the folder structure. This is all the context the model needs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Prompt sanitization is critical.&lt;/strong&gt; If your &lt;code&gt;Program.cs&lt;/code&gt; contains complex&lt;br&gt;
  string literals or nested colons, the AI might pass them through to your YAML,&lt;br&gt;
  breaking the Backstage parser. Always sanitize the output before writing the&lt;br&gt;
  file.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StringBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Project: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Folder structure:"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;AppendFolderStructure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csprojPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;programPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Program.cs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SearchOption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllDirectories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;programPath&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;programPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt itself uses few-shot examples to guide the model toward the output format we want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Summarize the project in 1-2 sentences based on the files provided. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"Do not output anything else. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"Examples of good output: "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"REST API service providing weather forecasts with temperature data\n"&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"ASP.NET MVC application with React frontend for managing todo items\n\n"&lt;/span&gt;
             &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&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;cts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;summaryBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Append&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;Finally, each summary is sanitized and assembled into a Backstage-compatible YAML entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;summaryBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" -"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;yamlEntry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$@"&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;apiVersion: backstage.io/v1alpha1&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;kind: Component&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;metadata:&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  name: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLowerInvariant&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  description: ""&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;span class="s"&gt;""&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;spec:&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  type: service&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  lifecycle: production&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  owner: group:default/engineering"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the generator against the target directory (use &lt;code&gt;.&lt;/code&gt; if you are already in the project root):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; ProjectSummarizer &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the AI streaming its summaries in real time:&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.amazonaws.com%2Fuploads%2Farticles%2Fsdtvort68qib4wtlco0d.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.amazonaws.com%2Fuploads%2Farticles%2Fsdtvort68qib4wtlco0d.png" alt="CLI output showing AI-generated project summaries" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most of the real work here is figuring out the prompt. Even a tiny change can produce a completely different output. I encourage you to experiment with the system prompt and the user prompt to see how it affects quality. That is the real learning here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating the AI Catalog with Backstage
&lt;/h2&gt;

&lt;p&gt;With the &lt;code&gt;catalog-info.yaml&lt;/code&gt; ready, we can integrate it into a Backstage instance. Install Backstage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @backstage/create-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow the prompts to name it &lt;code&gt;dev-portal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now point Backstage to your generated catalog file. Open &lt;code&gt;app-config.yaml&lt;/code&gt; in the &lt;code&gt;dev-portal&lt;/code&gt; directory and add the following under the &lt;code&gt;catalog&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;locations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../Backstage-Dev-Portal/catalog-info.yaml&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Component&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Backstage where to find the AI-generated service metadata. The &lt;code&gt;target&lt;/code&gt; path is relative to the Backstage root directory. Adjust it to point to wherever your generator wrote the &lt;code&gt;catalog-info.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To run it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;dev-portal
yarn dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3000&lt;/code&gt; in your browser. You should see all your services listed in the &lt;strong&gt;Software Catalog&lt;/strong&gt; with AI-generated summaries visible in the description column.&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.amazonaws.com%2Fuploads%2Farticles%2Fyh61nl93fhh546xdztei.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.amazonaws.com%2Fuploads%2Farticles%2Fyh61nl93fhh546xdztei.png" alt="Backstage service catalog showing AI-generated service descriptions" width="799" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying the Portal
&lt;/h2&gt;

&lt;p&gt;To host this, build your portal as a static site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn build:static
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push the output to GitHub and deploy to any &lt;strong&gt;static hosting provider&lt;/strong&gt;: Netlify, Azure Static Web Apps, Vercel, or even self-hosted on Kubernetes. Set the build command to &lt;code&gt;yarn build:static&lt;/code&gt; and the publish directory to &lt;code&gt;dist&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt;&lt;br&gt;
A static Backstage build is great for read-only catalogs. If you need dynamic&lt;br&gt;
  features like &lt;strong&gt;authentication&lt;/strong&gt;, &lt;strong&gt;real-time plugin backends&lt;/strong&gt;, or &lt;strong&gt;write&lt;br&gt;
  operations&lt;/strong&gt;, you will need to deploy the full Backstage backend as a Node.js&lt;br&gt;
  service instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Automating with CI/CD
&lt;/h2&gt;

&lt;p&gt;The real value comes from running the catalog generator automatically. Here is a GitHub Actions workflow that regenerates summaries on every push to &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update Backstage Catalog&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generate-catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup .NET&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-dotnet@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;dotnet-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;‘8.0.x’&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install and start Ollama&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -fsSL https://ollama.com/install.sh | sh&lt;/span&gt;
          &lt;span class="s"&gt;ollama serve &amp;amp;&lt;/span&gt;
          &lt;span class="s"&gt;sleep 5&lt;/span&gt;
          &lt;span class="s"&gt;ollama pull llama3:8b&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate catalog&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet run --project ProjectSummarizer -- "$GITHUB_WORKSPACE"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Commit updated catalog&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "github-actions"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "github-actions@github.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add catalog-info.yaml&lt;/span&gt;
          &lt;span class="s"&gt;git diff --cached --quiet || git commit -m "chore: regenerate AI catalog summaries"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;CI Performance:&lt;/strong&gt; Running Ollama in CI uses CPU-only inference by default. A&lt;br&gt;
  &lt;code&gt;llama3:8b&lt;/code&gt; summary takes about 20-30 seconds per project on a standard GitHub&lt;br&gt;
  runner. For a large monorepo, your CI bill will spike. Consider using a&lt;br&gt;
  persistent self-hosted runner with a GPU if you scale this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The practical rule is simple: automate the metadata generation where the code lives, but keep the UI (Backstage) as a thin, static client. This prevents the "stale documentation" problem without adding a heavy runtime dependency to your production environment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use narrow context:&lt;/strong&gt; Don't send the whole repo to the AI. Files like &lt;code&gt;Program.cs&lt;/code&gt; and &lt;code&gt;*.csproj&lt;/code&gt; are usually enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize strictly:&lt;/strong&gt; AI output is non-deterministic. Always strip colons and newlines before writing to YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start static:&lt;/strong&gt; A read-only static portal is 10x easier to maintain than a dynamic one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use OpenAI instead of Ollama?&lt;/strong&gt;&lt;br&gt;
Yes, but you will be sending your source code (or at least your &lt;code&gt;Program.cs&lt;/code&gt;) to a third party. Use a local model if security is a concern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this replace README files?&lt;/strong&gt;&lt;br&gt;
No. It replaces the "Service Directory" that usually lives in a spreadsheet. It points engineers to the README they actually need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle project renames?&lt;/strong&gt;&lt;br&gt;
The generator uses the folder or &lt;code&gt;.csproj&lt;/code&gt; name. If you rename them, Backstage will see it as a new component unless you map the identity stable-ly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I help teams build exactly this kind of internal tooling, from developer portals to platform engineering. &lt;a href="https://dev.to/resume"&gt;See my work&lt;/a&gt; or &lt;a href="https://dev.to/contact"&gt;get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>backstage</category>
      <category>idp</category>
    </item>
    <item>
      <title>SQLMesh for dbt users: the migration path</title>
      <dc:creator>Borys Generalov</dc:creator>
      <pubDate>Thu, 14 May 2026 19:39:36 +0000</pubDate>
      <link>https://dev.to/bgener/sqlmesh-for-dbt-users-the-migration-path-not-just-the-feature-list-1jn3</link>
      <guid>https://dev.to/bgener/sqlmesh-for-dbt-users-the-migration-path-not-just-the-feature-list-1jn3</guid>
      <description>&lt;p&gt;&lt;strong&gt;How to move from dbt to SQLMesh, understand the plan/apply cycle, leverage native column-level lineage, and run dev environments without schema suffixing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; an introduction to SQLMesh's core differences, the exact commands to run, how tests became audits, how dev environments work, and screenshots from a running demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is dbt
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Data Build Tool (dbt)&lt;/strong&gt; is an open-source framework that allows analysts and engineers to transform data in their warehouses by writing SQL &lt;code&gt;SELECT&lt;/code&gt; statements enriched with Jinja templating. This special syntax adds dynamic logic—like loops, variables, and dependency referencing—directly into your SQL. Instead of manually writing &lt;code&gt;CREATE TABLE&lt;/code&gt; statements, dbt takes your templated queries and automatically materializes them in the database for you. When a data platform scales, nested Jinja templates become impossible to debug, and copying production data to build staging environments gets expensive fast.&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.amazonaws.com%2Fuploads%2Farticles%2Fk22c3ogt7o33v902k2bk.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.amazonaws.com%2Fuploads%2Farticles%2Fk22c3ogt7o33v902k2bk.png" alt="Standard dbt documentation" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is SQLMesh
&lt;/h2&gt;

&lt;p&gt;This is where &lt;strong&gt;SQLMesh&lt;/strong&gt; comes in. As a modern framework challenging the status quo for the transformation (the 'T') phase of ETL/ELT pipelines, SQLMesh is designed specifically to address these growing pains. It provides a more robust, scalable way to manage data transformations by bringing strict software engineering practices—like stateful dry runs and zero-copy environments—directly into the data warehouse. Because it solves the exact operational bottlenecks that slow down mature data teams, it has rapidly grown in popularity, ultimately leading to its contribution to the Linux Foundation in March 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  Under the Hood: Text Templating vs. AST Parsing
&lt;/h2&gt;

&lt;p&gt;The fundamental difference between dbt and SQLMesh comes down to how they read your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dbt is a text templater.&lt;/strong&gt; It uses Jinja to stitch strings of SQL together and sends the final block to the data warehouse to execute. It doesn't semantically understand the SQL it generates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLMesh is a semantic parser.&lt;/strong&gt; It reads your code and builds an &lt;strong&gt;Abstract Syntax Tree (AST)&lt;/strong&gt; using its underlying engine, SQLGlot. Because SQLMesh understands your SQL &lt;em&gt;before&lt;/em&gt; it hits the warehouse, it knows exactly which columns are being selected, aliased, or joined at compile time.&lt;/p&gt;

&lt;p&gt;Because SQLMesh actually reads your code, it does three things that dbt cannot do natively:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A Terraform-like &lt;code&gt;plan&lt;/code&gt;/&lt;code&gt;apply&lt;/code&gt; cycle&lt;/strong&gt; that guarantees you know the exact impact before spending warehouse compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column-level lineage natively&lt;/strong&gt;, without requiring expensive third-party data catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Virtual Data Environments&lt;/strong&gt; that allow developers to test changes in isolation without physically copying production data.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The plan/apply cycle: seeing the impact
&lt;/h2&gt;

&lt;p&gt;In dbt, you run &lt;code&gt;dbt run&lt;/code&gt; and wait to see what breaks. In SQLMesh, you use a stateful workflow inspired by Terraform: &lt;code&gt;plan&lt;/code&gt; and &lt;code&gt;apply&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlmesh plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command evaluates your local SQL files against the current state of the database and generates an execution plan. It outputs exactly what will be built, whether it's a full refresh or incremental, and asks for confirmation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;======================================================================
Plan: prod
======================================================================
New environment `prod` will be created from `empty`

Added Models:
├── jaffle_shop.customers    (Full Refresh)
└── jaffle_shop.orders (Full Refresh)

Apply - Create prod environment and backfill models [y/n]:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you add a column to &lt;code&gt;stg_payments.sql&lt;/code&gt;, the next &lt;code&gt;sqlmesh plan&lt;/code&gt; highlights the real dependency chain before any warehouse compute is consumed. In this demo, &lt;code&gt;orders&lt;/code&gt; reads from &lt;code&gt;stg_payments&lt;/code&gt;, and &lt;code&gt;customers&lt;/code&gt; reads from &lt;code&gt;orders&lt;/code&gt;, so both appear as indirectly modified:&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.amazonaws.com%2Fuploads%2Farticles%2Ff7bit0ct7zirs92og3bo.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.amazonaws.com%2Fuploads%2Farticles%2Ff7bit0ct7zirs92og3bo.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You review the impact, then type &lt;code&gt;y&lt;/code&gt; to apply. You stop paying for blind warehouse queries just to see if your code works.&lt;/p&gt;

&lt;p&gt;The plan and apply execution from the running demo looks like this:&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.amazonaws.com%2Fuploads%2Farticles%2Ffflha7ye4snplxgjovq4.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.amazonaws.com%2Fuploads%2Farticles%2Ffflha7ye4snplxgjovq4.png" alt="SQLMesh showing the plan evaluation" 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.amazonaws.com%2Fuploads%2Farticles%2F5itj6mk93etyxhtu7jyj.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.amazonaws.com%2Fuploads%2Farticles%2F5itj6mk93etyxhtu7jyj.png" alt="SQLMesh showing the plan application" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Column-level lineage out of the box
&lt;/h2&gt;

&lt;p&gt;Because SQLMesh uses SQLGlot to parse your SQL, it natively understands how data flows from source to destination at the column level. It traces dependencies through complex aliases, window functions, and joins without you ever writing a YAML definition.&lt;/p&gt;

&lt;p&gt;SQLMesh does have a dbt-docs-style DAG view, but it is exposed through the data catalog and lineage graph instead of a separate static docs site. The useful difference is that the graph is tied to the selected SQLMesh environment and can show model fields, not only model boxes.&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.amazonaws.com%2Fuploads%2Farticles%2Fjch0vzkow5xuxt0i26k3.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.amazonaws.com%2Fuploads%2Farticles%2Fjch0vzkow5xuxt0i26k3.png" alt="SQLMesh data catalog" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lineage view for the demo model looks like this. The point is not that SQLMesh has a DAG. dbt already has that. The useful part is column visibility: you can inspect which upstream columns feed a downstream model, which is what makes impact analysis and PII tracing practical.&lt;/p&gt;

&lt;p&gt;In this small demo, the graph shows &lt;code&gt;seed_model&lt;/code&gt; feeding &lt;code&gt;incremental_model&lt;/code&gt;, then &lt;code&gt;full_model&lt;/code&gt;, with the columns visible on each node.&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.amazonaws.com%2Fuploads%2Farticles%2Fc2sg3k47vwzomhpk9rux.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.amazonaws.com%2Fuploads%2Farticles%2Fc2sg3k47vwzomhpk9rux.png" alt="SQLMesh UI lineage graph" width="800" height="235"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Use Case 1: Safe Deprecation of Columns
&lt;/h3&gt;

&lt;p&gt;Say you need to drop a deprecated &lt;code&gt;user_phone&lt;/code&gt; column from the production database.&lt;/p&gt;

&lt;p&gt;In a dbt setup, understanding the full impact is problematic. You would have to do a global text search for &lt;code&gt;user_phone&lt;/code&gt; across hundreds of SQL files. Even then, if the column was aliased (&lt;code&gt;SELECT user_phone AS phone_number&lt;/code&gt;), a simple text search might miss downstream models that rely on &lt;code&gt;phone_number&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;SQLMesh solves this instantly. By parsing the AST, it tracks the column through every alias and CTE. You simply open the built-in UI editor and click the column name.&lt;/p&gt;

&lt;p&gt;It visually traces and highlights exactly which downstream reporting tables or BI dashboards will break if you drop the source column. You can confidently deprecate columns without causing massive data outages.&lt;/p&gt;
&lt;h3&gt;
  
  
  Use Case 2: Tracing PII Data for Compliance
&lt;/h3&gt;

&lt;p&gt;Data privacy regulations (like GDPR or CCPA) require strict tracking of Personally Identifiable Information (PII). If you mistakenly join a table containing an &lt;code&gt;email_address&lt;/code&gt; into a public-facing metrics aggregate, it could result in a massive compliance violation.&lt;/p&gt;

&lt;p&gt;With SQLMesh, you can visually trace the flow of sensitive data using the built-in UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlmesh ui
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This spins up a local web server displaying an interactive lineage graph. For a sensitive field such as &lt;code&gt;email&lt;/code&gt;, the lineage should make the path easy to inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`raw_customers.email` (PII) -&amp;gt; `stg_customers.email` -&amp;gt; `customers.email`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the lineage is native, data governance teams can instantly verify that PII is masked or excluded before it reaches downstream aggregates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dev environments without schema suffixing (Virtual Environments)
&lt;/h2&gt;

&lt;p&gt;This is how SQLMesh directly cuts warehouse compute costs.&lt;/p&gt;

&lt;p&gt;In dbt, a development environment is physical. It's a target schema (e.g., &lt;code&gt;analytics_dev_yourname&lt;/code&gt;). When you build your models to test a change, you physically copy or rebuild the data into your dev schema.&lt;/p&gt;

&lt;p&gt;In SQLMesh, a dev environment is a &lt;strong&gt;Virtual Data Environment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;sqlmesh plan dev&lt;/code&gt;, SQLMesh doesn't copy data. Instead, it creates lightweight, virtualized database views that simply point to the existing physical tables from &lt;code&gt;prod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The SQLMesh UI keeps those environments visible in the same toolbar used by the editor, plan view, and data catalog. In this demo, &lt;code&gt;prod&lt;/code&gt; and &lt;code&gt;dev&lt;/code&gt; both exist, and &lt;code&gt;prod&lt;/code&gt; is marked as the production environment.&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.amazonaws.com%2Fuploads%2Farticles%2Fm4y9clbz47iak10m8tiu.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.amazonaws.com%2Fuploads%2Farticles%2Fm4y9clbz47iak10m8tiu.png" alt="SQLMesh UI environment" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;prod.customers&lt;/code&gt; can point to &lt;code&gt;customers_v1&lt;/code&gt;, while &lt;code&gt;prod.orders&lt;/code&gt; points to &lt;code&gt;orders_v1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dev.customers&lt;/code&gt; can point to a new &lt;code&gt;customers_v2&lt;/code&gt;, while &lt;code&gt;dev.orders&lt;/code&gt; still points to the existing &lt;code&gt;orders_v1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the changed model needs new physical storage. Everything unchanged can keep pointing at the already-built production tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Case 1: Multi-Developer Collaboration (Dev)
&lt;/h3&gt;

&lt;p&gt;In a fast-moving data team, multiple developers are working on different features simultaneously. Alice is updating the &lt;code&gt;customers&lt;/code&gt; model, and Bob is updating the &lt;code&gt;orders&lt;/code&gt; model.&lt;/p&gt;

&lt;p&gt;In traditional setups, Alice and Bob either step on each other's toes in a shared &lt;code&gt;staging&lt;/code&gt; schema, or they both have to spend 20 minutes copying gigabytes of data into &lt;code&gt;alice_dev&lt;/code&gt; and &lt;code&gt;bob_dev&lt;/code&gt; schemas before they can start working.&lt;/p&gt;

&lt;p&gt;With SQLMesh, Alice simply creates her own virtual environment (&lt;code&gt;sqlmesh plan alice_feature&lt;/code&gt;). SQLMesh builds &lt;em&gt;only&lt;/em&gt; her modified &lt;code&gt;customers&lt;/code&gt; table physically, while her &lt;code&gt;orders&lt;/code&gt; view points to the production physical table. Bob does the same for his feature. They get perfect isolation instantly, with zero duplicated data and zero wasted compute cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Case 2: Instant Rollbacks and Blue/Green Deployments (Prod)
&lt;/h3&gt;

&lt;p&gt;Deploying pipeline changes to production is risky. A flawed query can corrupt downstream tables and break executive BI dashboards.&lt;/p&gt;

&lt;p&gt;SQLMesh handles production deployments using &lt;strong&gt;Blue/Green deployments&lt;/strong&gt; natively via virtual environments.&lt;/p&gt;

&lt;p&gt;When you merge your code to &lt;code&gt;main&lt;/code&gt; and deploy, SQLMesh builds the new physical tables in the background (the "Green" state). Your &lt;code&gt;prod&lt;/code&gt; environment views still point to the old tables (the "Blue" state).&lt;/p&gt;

&lt;p&gt;Once the new tables are fully built, populated, and automatically audited for quality, SQLMesh simply updates the &lt;code&gt;prod&lt;/code&gt; views to point to the new physical tables. This pointer swap takes milliseconds. If a bug is discovered after deployment, you can instantly rollback by swapping the view pointers back to the previous physical tables. No data needs to be rebuilt, providing a massive safety net for data teams.&lt;/p&gt;




&lt;h2&gt;
  
  
  Upgrading a Model: dbt vs SQLMesh
&lt;/h2&gt;

&lt;p&gt;Here is what this looks like in practice. We need to build a &lt;code&gt;customers&lt;/code&gt; model that cleans up user data from &lt;code&gt;jaffle_shop.stg_customers&lt;/code&gt; and joins it with &lt;code&gt;jaffle_shop.orders&lt;/code&gt; to calculate lifetime value.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;dbt&lt;/strong&gt;, you first write the SQL template (&lt;code&gt;models/customers.sql&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;materialized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'table'&lt;/span&gt;
&lt;span class="p"&gt;)&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;customer_orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&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;first_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&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;most_recent_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;number_of_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&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;customer_lifetime_value&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'returned'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signup_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;most_recent_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number_of_orders&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;number_of_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_lifetime_value&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_lifetime_value&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stg_customers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customer_orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you're not done. In dbt, your SQL file only contains the logic. To validate that the output data is actually correct—for example, to test that your &lt;code&gt;customer_id&lt;/code&gt; is unique and never null—you have to leave your SQL file, open a completely separate YAML configuration file (&lt;code&gt;models/schema.yml&lt;/code&gt;), and write your validation tests there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customers&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customer_id&lt;/span&gt;
        &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;unique&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;not_null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dbt docs graph for this looks like this:&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.amazonaws.com%2Fuploads%2Farticles%2F3lhnkvvqzive6lg5bu0u.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.amazonaws.com%2Fuploads%2Farticles%2F3lhnkvvqzive6lg5bu0u.png" alt="dbt docs lineage graph" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you migrate this same model to &lt;strong&gt;SQLMesh&lt;/strong&gt;, the scattered configuration disappears. Everything—metadata, dependencies, materialization logic, and testing—consolidates into the SQL file itself via the &lt;code&gt;MODEL&lt;/code&gt; block.&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.amazonaws.com%2Fuploads%2Farticles%2Fnfa221bvzu8k8kivetgn.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.amazonaws.com%2Fuploads%2Farticles%2Fnfa221bvzu8k8kivetgn.png" alt="SQLMesh UI Editor" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;jaffle_shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="k"&gt;FULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;grain&lt;/span&gt; &lt;span class="n"&gt;customer_id&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;customer_orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&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;first_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&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;most_recent_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;number_of_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&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;customer_lifetime_value&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jaffle_shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'returned'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signup_date&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signup_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_order_date&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;first_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;most_recent_order_date&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;most_recent_order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number_of_orders&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="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;number_of_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_lifetime_value&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="nb"&gt;DOUBLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_lifetime_value&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jaffle_shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stg_customers&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customer_orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what happened here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No Jinja Refs:&lt;/strong&gt; You simply query &lt;code&gt;jaffle_shop.stg_customers&lt;/code&gt; and &lt;code&gt;jaffle_shop.orders&lt;/code&gt; using standard SQL. Because SQLMesh parses the AST, it automatically detects the dependencies for both tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No YAML Tests:&lt;/strong&gt; The &lt;code&gt;grain customer_id&lt;/code&gt; property inside the &lt;code&gt;MODEL&lt;/code&gt; block automatically declares the natural key. You do not need to write explicit tests. When you want to validate the data, you simply run &lt;code&gt;sqlmesh audit&lt;/code&gt; in the terminal, and SQLMesh will automatically generate and execute the uniqueness and not-null validation checks for you based on that grain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The generated DAG for this model in SQLMesh looks like this:&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/.%2Fsqlmesh-dag.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/.%2Fsqlmesh-dag.png" alt="SQLMesh static DAG generated from the migrated project" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the closest match to the familiar dbt docs graph: a model dependency DAG generated from parsed SQL. The browser UI goes one step further by letting you inspect the same dependency chain with environment context and column metadata.&lt;/p&gt;

&lt;p&gt;If you want to try this yourself locally (using DuckDB, so no warehouse account needed), installation is simple:&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; &lt;span class="s2"&gt;"sqlmesh[duckdb]"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir &lt;/span&gt;sqlmesh-demo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;sqlmesh-demo
sqlmesh init &lt;span class="nt"&gt;-t&lt;/span&gt; empty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Automating this with CI/CD (GitHub Actions)
&lt;/h2&gt;

&lt;p&gt;The plan/apply cycle and virtual environments are impressive locally, but they become a superpower when integrated into your CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;SQLMesh provides a native GitHub Action (&lt;code&gt;TobikoData/sqlmesh-action&lt;/code&gt;) that automates this entire workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The PR Plan:&lt;/strong&gt; When a developer opens a Pull Request, the GitHub Action automatically creates a temporary Virtual Environment (e.g., &lt;code&gt;pr_123&lt;/code&gt;) and runs a plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated Review:&lt;/strong&gt; The bot posts the exact execution plan as a comment directly on the PR. The reviewer instantly sees which models are modified and which downstream models will be impacted, without having to run anything locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-Copy Deployment:&lt;/strong&gt; Upon merging to &lt;code&gt;main&lt;/code&gt;, the deployment job doesn't need to rebuild the data. It simply swaps the pointers in the &lt;code&gt;prod&lt;/code&gt; environment to reference the physical tables that were already built and tested during the PR.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This guarantees that &lt;code&gt;main&lt;/code&gt; is always in a deployable state and developers never accidentally merge breaking schema changes into production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Moving from text-based templating to semantic AST parsing is the structural shift required to solve data environment bloat and lineage blindness.&lt;/p&gt;

&lt;p&gt;To evaluate this transition yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audit your current warehouse spend strictly related to developer sandbox schemas.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;sqlmesh init --template dbt&lt;/code&gt; on a local branch of your existing dbt project.&lt;/li&gt;
&lt;li&gt;Migrate 5 connected models to the &lt;code&gt;MODEL&lt;/code&gt; block syntax.&lt;/li&gt;
&lt;li&gt;Execute &lt;code&gt;sqlmesh plan dev&lt;/code&gt; to observe the virtual pointer creation in your warehouse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the tool understands the code, the infrastructure manages itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do I need to rewrite all my dbt SQL immediately?&lt;/strong&gt;&lt;br&gt;
No, SQLMesh can parse and run your existing Jinja-based dbt models directly using its dbt adapter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does SQLMesh require DuckDB?&lt;/strong&gt;&lt;br&gt;
No, DuckDB is just used here for a fast local demo; SQLMesh natively supports Snowflake, BigQuery, Databricks, and Redshift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How does SQLMesh handle dbt macros?&lt;/strong&gt;&lt;br&gt;
It executes existing dbt macros perfectly during the transition, though rewriting them as native Python macros eventually unlocks deeper AST-level validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is this relevant if my team only has three data models?&lt;/strong&gt;&lt;br&gt;
The immediate value for small teams is the automated testing and plan visibility, but the cost savings of virtual environments truly compound at scale.&lt;/p&gt;

</description>
      <category>sqlmesh</category>
      <category>dbt</category>
      <category>duckdb</category>
    </item>
    <item>
      <title>OpenTelemetry custom spans in .NET: seeing what your code decided</title>
      <dc:creator>Borys Generalov</dc:creator>
      <pubDate>Thu, 07 May 2026 13:55:09 +0000</pubDate>
      <link>https://dev.to/bgener/opentelemetry-custom-spans-in-net-seeing-what-your-code-decided-4ma6</link>
      <guid>https://dev.to/bgener/opentelemetry-custom-spans-in-net-seeing-what-your-code-decided-4ma6</guid>
      <description>&lt;p&gt;&lt;strong&gt;How to instrument business decisions that auto-instrumentation cannot see. Events, span kinds, cross-process context propagation over RabbitMQ, and baggage” with a working .NET 10 demo.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; when to use custom spans, what to put on them, and how to avoid the traps. How to name spans, what to tag, when to use events instead, context propagation between services over RabbitMQ, and baggage that travels without any method parameters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://github.com/bgener/otel-custom-spans" rel="noopener noreferrer"&gt;github.com/bgener/otel-custom-spans&lt;/a&gt;” two .NET 10 services, RabbitMQ, Aspire Dashboard and Jaeger. &lt;code&gt;docker compose up --build&lt;/code&gt; and you have a working trace across two processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenTelemetry spans
&lt;/h2&gt;

&lt;p&gt;A trace is the full path of a request through your system. Each step in that path is a span: a named, timed unit of work with attributes attached. OpenTelemetry's auto-instrumentation creates spans for the calls it understands: inbound HTTP requests, outbound database queries, gRPC calls, message queue operations. For each of those you see the operation name, duration, and whether it succeeded.&lt;/p&gt;

&lt;p&gt;But auto-instrumentation stops at the call boundary. Your .NET service runs business logic and makes decisions. It returns a result without throwing anything. Something is off, but none of that is visible.&lt;/p&gt;

&lt;p&gt;You probably recognize this from the era of heavy &lt;code&gt;log.Debug&lt;/code&gt; and &lt;code&gt;log.Verbose&lt;/code&gt;. One log statement after every &lt;code&gt;if&lt;/code&gt; clause, every internal state worth knowing. The code got noisy and barely readable.&lt;/p&gt;

&lt;p&gt;Custom spans are the same idea done properly. Structured attributes, timestamped, sitting in the trace right next to the auto-instrumented calls.&lt;/p&gt;




&lt;h2&gt;
  
  
  ActivitySource and your first span
&lt;/h2&gt;

&lt;p&gt;In .NET, custom spans are built with &lt;code&gt;ActivitySource&lt;/code&gt;. You create one per service or logical area of code, start activities from it, and attach attributes that describe what your code decided. The pattern works the same whether you are in an API handler, a domain service, or a background worker.&lt;/p&gt;

&lt;p&gt;When you call &lt;code&gt;StartActivity()&lt;/code&gt;, the new span automatically becomes the active span for the current async execution context. Nested calls can reach it via &lt;code&gt;Activity.Current&lt;/code&gt; without you threading a variable through every method signature. Auto-instrumented spans work the same way: the inbound HTTP span created by ASP.NET Core middleware is also an &lt;code&gt;Activity&lt;/code&gt;, and your custom spans nest inside it automatically.&lt;/p&gt;

&lt;p&gt;The demo centralizes source creation in a static class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Observability&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"weather-api"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ServiceName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One source per service. The name matches the service name so spans are grouped correctly in your APM backend.&lt;/p&gt;

&lt;p&gt;Register it before exporting anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tracing&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAspNetCoreInstrumentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOtlpExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otelOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AspireDashboardEndpoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution:&lt;/strong&gt;&lt;br&gt;
Skip &lt;code&gt;AddSource&lt;/code&gt; and the spans are created but never exported. No error. No warning. Just silence. I have stared at an empty trace viewer for an embarrassing amount of time before catching this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is the weather forecast service from the demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WeatherService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivitySource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;WeatherForecast&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;GetForecast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"generate forecast"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"forecast.days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBaggage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;forecast&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Enumerable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;WeatherForecast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;DateOnly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromDateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                &lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;55&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;_summaries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_summaries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;)]))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"forecast.min_temp_c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forecast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemperatureC&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"forecast.max_temp_c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forecast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemperatureC&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;forecast&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are on this span: a name, a kind, and attributes.&lt;/p&gt;

&lt;p&gt;The name is how this operation appears in the trace viewer. Keep it low-cardinality: name the class of operation, not the instance. OTel recommends &lt;code&gt;{verb} {noun}&lt;/code&gt; with spaces: &lt;code&gt;generate forecast&lt;/code&gt;, &lt;code&gt;process payment&lt;/code&gt;, &lt;code&gt;write record&lt;/code&gt;. This mirrors how HTTP (&lt;code&gt;GET /forecast&lt;/code&gt;) and database (&lt;code&gt;SELECT weather&lt;/code&gt;) name operations. &lt;code&gt;generate forecast&lt;/code&gt; is a span name. &lt;code&gt;generate forecast london&lt;/code&gt; is not â€” the city belongs in an attribute. See the &lt;a href="https://opentelemetry.io/blog/2025/how-to-name-your-spans/" rel="noopener noreferrer"&gt;OTel naming guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The attribute &lt;code&gt;forecast.days&lt;/code&gt; travels with every trace for this operation. The &lt;code&gt;client.id&lt;/code&gt; comes from baggage set at the API edge â€” covered in the cross-process section. Record inputs before the work, outcomes after it. That is the pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Events
&lt;/h2&gt;

&lt;p&gt;Attributes describe the final state of the span. But some operations have decision points inside them that attributes cannot capture.&lt;/p&gt;

&lt;p&gt;The forecast can include sub-zero days. That is a fact worth recording at the moment it was detected, not as a summary after the fact. An attribute set after the loop collapses the timeline into a single value. An event preserves when inside the span the thing happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;coldDays&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;forecast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Count&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;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TemperatureC&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coldDays&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;AddEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ActivityEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"forecast.cold_days_detected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ActivityTagsCollection&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"cold.day_count"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;coldDays&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"cold.threshold_c"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events are timestamped. In the trace viewer they appear pinned at the exact millisecond they fired, inside the span timeline. If the span was slow, you can see whether the detection happened early or late and whether it correlates with the latency. An attribute cannot tell you that.&lt;/p&gt;

&lt;p&gt;OTel event names follow the general naming conventions: lowercase, dot-separated namespaces, snake_case for multi-word parts. &lt;code&gt;forecast.cold_days_detected&lt;/code&gt; is correct. &lt;code&gt;forecastColdDaysDetected&lt;/code&gt; is not.&lt;/p&gt;

&lt;p&gt;The rule: use an attribute for facts about the operation as a whole. Use an event for things that happened at a specific moment inside it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Custom span roles and error signals
&lt;/h2&gt;

&lt;p&gt;When you add a custom span, two things determine where it shows up and what it feeds: the kind and the status.&lt;/p&gt;

&lt;p&gt;The kind tells your backend what role the span plays. There are five:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"handle request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// receiving work&lt;/span&gt;
&lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"generate forecast"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// internal logic&lt;/span&gt;
&lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"call stored procedure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// calling something&lt;/span&gt;
&lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"publish event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Producer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// sending to a queue&lt;/span&gt;
&lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"process event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Consumer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// reading from a queue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the wrong one and your span shows up in the wrong dashboard. Backends that derive metrics from spans emit different time series depending on kind. Server spans become incoming-request RED metrics. Client spans become outbound-dependency metrics. Producer and Consumer feed messaging throughput. Internal appears only inside traces.&lt;/p&gt;

&lt;p&gt;Mark a stored proc call as &lt;code&gt;Internal&lt;/code&gt; and the dependency dashboard goes blank for that call. Mark a queue publish as &lt;code&gt;Client&lt;/code&gt; and the messaging panel never sees it. Kind is not cosmetic.&lt;/p&gt;

&lt;p&gt;Status is separate from exceptions. A business rejection is not an exception. Nothing throws. But it is still an &lt;code&gt;Error&lt;/code&gt; outcome from the trace's perspective.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;CoverageMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ActivityStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"city outside coverage area"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your error rate dashboard now reflects business failures, not just crashes. This is the gap between SRE error rates and product success rates that you usually learn to live with.&lt;/p&gt;

&lt;p&gt;For real exceptions, the demo consumer records them with full structured context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;AddException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ActivityStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="nf"&gt;BasicNackAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeliveryTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requeue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddException&lt;/code&gt; records the exception as a span event with &lt;code&gt;exception.type&lt;/code&gt;, &lt;code&gt;exception.message&lt;/code&gt;, and &lt;code&gt;exception.stacktrace&lt;/code&gt; as structured fields. Searchable, filterable, aggregatable in any OTel-compatible backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Span links
&lt;/h2&gt;

&lt;p&gt;Most spans have one parent. Batch consumers do not. A consumer drains 50 messages from a queue in one poll. Each message came from a different upstream request, each with its own trace ID. A span needs a parent to belong to a trace, but with 50 different upstream traces there is no single parent.&lt;/p&gt;

&lt;p&gt;Span links solve this. A span can carry references to spans in other traces. The consumer span becomes the root of its own trace and holds pointers back to all 50 producer spans. From the consumer trace you click through to any of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;links&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ActivityLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExtractParentContext&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"consume forecast batch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parentContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without links, batch consumers either pick one arbitrary parent and silently lose the rest, or appear as orphan traces with no context.&lt;/p&gt;

&lt;p&gt;Common cases: batch processors, dead-letter retries linking back to the original failure, fan-out workflows where one request kicks off many async jobs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cross-process tracing
&lt;/h2&gt;

&lt;p&gt;When both ends of a call know about OTel, trace context flows automatically. HTTP, gRPC, async/await: the .NET runtime handles all of it. The hard case is any transport the SDK has no built-in instrumentation for. RabbitMQ is one of those.&lt;/p&gt;

&lt;h3&gt;
  
  
  Baggage
&lt;/h3&gt;

&lt;p&gt;Before the producer and consumer, it helps to understand baggage. Baggage is a set of key-value pairs the propagator carries across every process boundary automatically. Set it once at the API edge and it appears on every span in every downstream service without any method parameters.&lt;/p&gt;

&lt;p&gt;The demo sets &lt;code&gt;client.id&lt;/code&gt; from the &lt;code&gt;X-Client-Id&lt;/code&gt; request header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"X-Client-Id"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetBaggage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three hops downstream, in the worker process, without passing anything as a parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBaggage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire a &lt;code&gt;BaggageActivityProcessor&lt;/code&gt; into your tracer (see the OTel contrib repo) and you never call &lt;code&gt;SetTag&lt;/code&gt; for baggage values at all. The processor copies them onto every span automatically as it closes. Every dashboard you build gets &lt;code&gt;client.id&lt;/code&gt; as a free filter.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution:&lt;/strong&gt;&lt;br&gt;
Do not put sensitive data in baggage. It travels in plain text in headers. If your OTel collector exports to a third-party SaaS, you have shipped that data across your trust boundary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Producer to queue
&lt;/h3&gt;

&lt;p&gt;The publisher creates a Producer span, tags it with messaging attributes, then injects W3C trace context and baggage into the message headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WeatherEventPublisher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IConnection&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivitySource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WeatherForecastServedEvent&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"publish weatherforecast event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Producer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rabbitmq"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.destination.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QueueNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WeatherForecastServed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.operation.type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"publish"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateChannelAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;BasicProperties&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Headers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;DeliveryMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DeliveryModes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;MessageId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="c1"&gt;// Inject W3C trace context + baggage into message headers.&lt;/span&gt;
        &lt;span class="c1"&gt;// The worker extracts these to continue this trace across processes.&lt;/span&gt;
        &lt;span class="n"&gt;Propagators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultTextMapPropagator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PropagationContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BasicPublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;routingKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;QueueNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WeatherForecastServed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;mandatory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;basicProperties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.message.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth distinguishing. The &lt;code&gt;SetTag&lt;/code&gt; calls attach attributes to this specific span â€” they describe this publish operation and appear in the trace detail view. The &lt;code&gt;Propagators.Inject&lt;/code&gt; call encodes the W3C trace context and baggage into the message headers so the worker can restore the trace on the other side. Span attributes stay on the span. Baggage travels forward.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;connection&lt;/code&gt; is &lt;code&gt;IConnection&lt;/code&gt; from &lt;code&gt;RabbitMQ.Client&lt;/code&gt; v7, which added async channel creation.&lt;/p&gt;

&lt;p&gt;The headers on the wire after &lt;code&gt;Inject&lt;/code&gt; runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;traceparent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01&lt;/span&gt;
&lt;span class="py"&gt;baggage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;client.id=my-client&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;traceparent&lt;/code&gt; format is the &lt;a href="https://www.w3.org/TR/trace-context/" rel="noopener noreferrer"&gt;W3C Trace Context spec&lt;/a&gt;: &lt;code&gt;{version}-{trace-id}-{parent-id}-{flags}&lt;/code&gt;. The 32-character trace ID identifies the whole distributed trace. The 16-character parent ID is the span ID of the publisher span. &lt;code&gt;01&lt;/code&gt; means sampled. This is the same format HTTP headers carry automatically â€” here you are writing it manually into AMQP message headers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consumer
&lt;/h3&gt;

&lt;p&gt;The worker is a completely separate process with its own &lt;code&gt;Program.cs&lt;/code&gt; and its own OTel registration. It extracts context from the message headers and passes it as the parent when starting the consumer span:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ProcessMessageAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BasicGetResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;parentContext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Propagator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BasicProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
                &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;  &lt;span class="p"&gt;=&amp;gt;&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;_&lt;/span&gt;            &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parentContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivitySource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"process weatherforecast event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;parentContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivityContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// links consumer span to producer span&lt;/span&gt;

    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rabbitmq"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.operation.type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"process"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"messaging.message.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BasicProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBaggage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WeatherForecastServedEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;SimulateAnalyticsWriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="nf"&gt;BasicAckAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeliveryTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines connect the processes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;parentContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivityContext&lt;/span&gt;            &lt;span class="c1"&gt;// restores trace ID + parent span ID from message headers&lt;/span&gt;
&lt;span class="n"&gt;Baggage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parentContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Baggage&lt;/span&gt; &lt;span class="c1"&gt;// restores baggage that travelled with the message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer creates a nested child span for the analytics write â€” a separate &lt;code&gt;Client&lt;/code&gt; span showing the DB operation as a distinct step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SimulateAnalyticsWriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WeatherForecastServedEvent&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivitySource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"write analytics record"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&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;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"db.system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"postgresql"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"db.operation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"INSERT"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"db.sql.table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"weather_analytics"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you see in Aspire Dashboard or Jaeger after one request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /weatherforecast                          (Server, ForecastApi)
  generate forecast                           (Internal, ForecastApi)
  publish weatherforecast event               (Producer, ForecastApi)
                    ----- RabbitMQ boundary -----
process weatherforecast event                 (Consumer, ForecastWorker)
  write analytics record                      (Client, ForecastWorker)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two processes. One trace. The consumer span hangs off the producer span as if they were in the same call stack.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;Inject&lt;/code&gt; and &lt;code&gt;Extract&lt;/code&gt;: two completely disconnected traces. The forecast request appears to end at the publish. The worker appears to start a job for no apparent reason. The first time a customer reports a bug you discover the only thing connecting the API request to the analytics record is a wall-clock timestamp.&lt;/p&gt;




&lt;h2&gt;
  
  
  Spans inside the database
&lt;/h2&gt;

&lt;p&gt;This section covers a scenario not in the demo: stored procedures and how to get visibility into what happens inside the database.&lt;/p&gt;

&lt;p&gt;When .NET calls Postgres via Npgsql, EF Core records a SQL client span. Postgres has no idea your trace exists. The proc executes in isolation. The bridge is &lt;strong&gt;SQLCommenter&lt;/strong&gt;, a format that encodes trace context as a SQL comment the database can read.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Info:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Database support:&lt;/strong&gt; SQLCommenter works with PostgreSQL and MySQL. SQL Server and Oracle have no equivalent. If your stack runs a database without SQLCommenter support, wrap the call in a custom span and capture output parameters as attributes instead.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"call stored procedure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;traceparent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="s"&gt;$"00-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;TraceId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToHexString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpanId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToHexString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;-01"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateCommand&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="s"&gt;$"/*traceparent='&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;traceparent&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;'*/ CALL get_forecast_for_city(@city, @tier)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddWithValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@city"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddWithValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@tier"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteNonQueryAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF Core 9 with Npgsql ships a built-in &lt;code&gt;EnableSqlCommenter()&lt;/code&gt; option that does this for you. Use it if your driver supports it.&lt;/p&gt;

&lt;p&gt;Beyond SQLCommenter, there is &lt;code&gt;pg_tracing&lt;/code&gt;: a Postgres extension open-sourced by Datadog that reads the &lt;code&gt;traceparent&lt;/code&gt; comment and emits OTLP spans for the engine work it can see: parse, plan, execute, nested function calls, trigger execution. Postgres 16+ only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="k"&gt;procedure&lt;/span&gt;  &lt;span class="p"&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;your&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;
  &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;
  &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;execute&lt;/span&gt;
    &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;get_forecast_for_city&lt;/span&gt;
      &lt;span class="n"&gt;pg&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sensor_readings&lt;/span&gt;
      &lt;span class="n"&gt;pg&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;city_forecast&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caveats: Postgres 16 or higher only. You build the extension yourself or use a Datadog prebuilt image. It does not let you write &lt;code&gt;start_span()&lt;/code&gt; inside PL/pgSQL. Instrumentation happens at the engine level, not in user code. MySQL and SQL Server have nothing comparable.&lt;/p&gt;

&lt;p&gt;For any team running Postgres, this is the most direct way to stop treating stored procedures as a blackbox.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cardinality trap
&lt;/h2&gt;

&lt;p&gt;Every major APM backend converts span names into metric labels. Put a dynamic value in the span name and you get a unique time series for every unique value. At production scale that exhausts cardinality limits and your backend starts dropping data. It looks completely innocent when you write it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Do NOT do this&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"generate forecast &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SigNoz, Datadog APM, Honeycomb, Grafana Tempo all run a spanmetrics processor that emits time series keyed on &lt;code&gt;span_name&lt;/code&gt;. A few hundred cities is fine. A few thousand customer IDs in the span name is not. You find out at 2am when dashboards stop refreshing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Correct&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"generate forecast"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"forecast.city"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same signal. No explosion. The span name stays &lt;code&gt;generate forecast&lt;/code&gt;. The city is a filterable attribute. If it changes per request: attribute. If it names the kind of operation: span name.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real cost
&lt;/h2&gt;

&lt;p&gt;Custom spans have a maintenance cost nobody mentions in the enthusiastic how-to posts.&lt;/p&gt;

&lt;p&gt;Business logic changes. When it does, the instrumentation has to change with it. If the attribute you added six months ago no longer reflects what the code does, you now have misleading data in traces. Not missing data. Wrong data. I would rather have an empty trace than a trace that confidently tells me the wrong thing.&lt;/p&gt;

&lt;p&gt;I would not use this in production unless your team treats span attributes with the same review discipline as API contracts. They are observable behavior. When they drift silently they break dashboards, misfire alerts, and send engineers in the wrong direction during the worst possible moment.&lt;/p&gt;

&lt;p&gt;There is also the coordination overhead. You need a naming convention the whole team follows. You need someone who knows what sources are registered, what each attribute means, and why it was added. A bit of documentation here, kept up to date, is not glamorous but it is real engineering infrastructure.&lt;/p&gt;

&lt;p&gt;On a team of three with two services, nobody will notice if you skip that. On a platform with twenty services and four teams it becomes a governance problem before it becomes an engineering one.&lt;/p&gt;

&lt;p&gt;The alternative is debugging production with a green trace and a prayer. I have done both. Pick your poison.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Auto-instrumentation covers infrastructure. Custom spans cover intent: what the code chose to do, not just what it called.&lt;/p&gt;

&lt;p&gt;Patterns to take from here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One &lt;code&gt;ActivitySource&lt;/code&gt; per service&lt;/strong&gt;, centralized in a static &lt;code&gt;Observability&lt;/code&gt; class. Register it by name in &lt;code&gt;AddSource()&lt;/code&gt; before you have ten sources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Span name for the operation kind, attributes for variable data.&lt;/strong&gt; If it changes per request, it belongs in a tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match &lt;code&gt;ActivityKind&lt;/code&gt; to the role.&lt;/strong&gt; Wrong kind, wrong dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject and extract everywhere your runtime cannot.&lt;/strong&gt; Queues, SQL comments, file metadata, custom protocols.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set baggage at the API edge once.&lt;/strong&gt; It travels to every span in every downstream service automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review span attributes in the same PR as the business logic they describe.&lt;/strong&gt; They are observable behavior. When they drift silently they break dashboards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The portable rule: if it names the kind of thing that happened, it is the span name. If it describes the operation, it is an attribute. If it happened at a specific moment inside the operation, it is an event.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What is the difference between a span event and a span attribute?&lt;/strong&gt; Attributes describe the span as a whole. Events are timestamped points inside the span with their own attributes. Use an attribute for &lt;code&gt;forecast.days = 5&lt;/code&gt;. Use an event for &lt;code&gt;forecast.cold_days_detected at 12ms with cold.day_count = 3&lt;/code&gt;. The event tells you when inside the span the thing happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does &lt;code&gt;ActivityKind&lt;/code&gt; actually change?&lt;/strong&gt; It changes which dashboards your span shows up in. Backends that derive metrics from spans emit different time series for Server vs Client vs Producer vs Consumer. Internal appears only inside traces. Wrong kind, wrong dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I propagate trace context through RabbitMQ?&lt;/strong&gt; Use &lt;code&gt;Propagators.DefaultTextMapPropagator.Inject&lt;/code&gt; on the producer side, passing &lt;code&gt;Activity.Current!.Context&lt;/code&gt; and &lt;code&gt;Baggage.Current&lt;/code&gt;. On the consumer side use &lt;code&gt;Extract&lt;/code&gt; with a getter that reads the message headers, then pass &lt;code&gt;parentContext.ActivityContext&lt;/code&gt; to &lt;code&gt;StartActivity&lt;/code&gt; and restore &lt;code&gt;Baggage.Current = parentContext.Baggage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is baggage and when should I use it?&lt;/strong&gt; Baggage is a set of key-value pairs the propagator carries across every process boundary automatically. Use it for cross-cutting context: tenant ID, client ID, correlation tokens. Do not put sensitive data in baggage â€” it travels in plain text in headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work with .NET Aspire?&lt;/strong&gt; Yes. Register each &lt;code&gt;ActivitySource&lt;/code&gt; via &lt;code&gt;AddSource()&lt;/code&gt; inside &lt;code&gt;ConfigureOpenTelemetry()&lt;/code&gt; in &lt;code&gt;ServiceDefaults&lt;/code&gt;. Aspire does not auto-discover custom sources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I put high-cardinality values in the span name?&lt;/strong&gt; Backends that derive metrics from spans turn the span name into a metric label. Each unique name becomes a unique time series. At production scale this exhausts cardinality limits and causes data drops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use &lt;code&gt;Activity.Current&lt;/code&gt; instead of a local variable?&lt;/strong&gt; Yes. &lt;code&gt;Activity.Current&lt;/code&gt; returns the ambient span for the current async context. Useful in nested calls where you want to attach an attribute without threading the activity reference through every method signature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get spans from inside a stored procedure?&lt;/strong&gt; Only on PostgreSQL 16+, using the &lt;code&gt;pg_tracing&lt;/code&gt; extension. It reads the &lt;code&gt;traceparent&lt;/code&gt; from a SQLCommenter comment and emits OTLP spans for parse, plan, execute, function calls, and triggers. MySQL and SQL Server have no equivalent.&lt;/p&gt;

</description>
      <category>development</category>
      <category>devops</category>
    </item>
    <item>
      <title>Build an AI-Powered Developer Portal with Backstage and .NET</title>
      <dc:creator>Borys Generalov</dc:creator>
      <pubDate>Fri, 01 May 2026 21:17:40 +0000</pubDate>
      <link>https://dev.to/bgener/build-an-ai-powered-developer-portal-with-backstage-and-net-2728</link>
      <guid>https://dev.to/bgener/build-an-ai-powered-developer-portal-with-backstage-and-net-2728</guid>
      <description>&lt;h2&gt;
  
  
  Build an AI-Powered Developer Portal with Backstage and .NET
&lt;/h2&gt;

&lt;p&gt;Want to apply AI, not just read about it? Most tutorials stop at a "Hello World" chatbot. We are going to build something that actually solves a common engineering headache: stale documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who this is for
&lt;/h3&gt;

&lt;p&gt;This guide is for platform engineers and .NET developers who need to organize a growing software landscape without forcing teams to manually write YAML files.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you will build
&lt;/h3&gt;

&lt;p&gt;You will build a &lt;strong&gt;dynamic developer portal&lt;/strong&gt; using &lt;a href="https://backstage.io/" rel="noopener noreferrer"&gt;Backstage&lt;/a&gt; that automatically populates its service catalog. We will use a .NET CLI tool to scan source code and use local AI (&lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;) to generate summaries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source repo:&lt;/strong&gt; &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;demo-backstage-catalog-generator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraint:&lt;/strong&gt; We use local inference only. No source code ever leaves your machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you ever needed to update a service, but forgot what it does? Or spent time trying to understand code you have not touched in months? We usually solve this with a README.md that nobody updates, or a wiki nobody keeps current.&lt;/p&gt;

&lt;p&gt;An Internal Developer Portal (IDP) solves this by making the software landscape visible, but only if the data is fresh. Automation is the only way to avoid the "stale metadata" trap.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
    &lt;strong&gt;Want to skip ahead?&lt;/strong&gt; Check out the &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;complete working demo on&lt;br&gt;
    GitHub&lt;/a&gt; with all&lt;br&gt;
    the code ready to run.&lt;br&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;p&gt;Before we start, make sure you have the following installed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dotnet.microsoft.com/en-us/download" rel="noopener noreferrer"&gt;.NET SDK 8+&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; (includes &lt;code&gt;npx&lt;/code&gt; and &lt;code&gt;yarn&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; with the &lt;code&gt;llama3:8b&lt;/code&gt; model pulled&lt;/li&gt;
&lt;li&gt;A GitHub account (for hosting and deployment)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why Does an Internal Developer Portal Matter?
&lt;/h3&gt;

&lt;p&gt;The term "Internal Developer Portal" (IDP) can be a little misleading, since it sounds like a tool exclusively for developers only. In reality, it functions as an &lt;strong&gt;internal organizational portal focused entirely on your software portfolio&lt;/strong&gt;. Unlike a general-purpose SharePoint site where everything is dumped in one place and nothing is easy to find, an IDP is deliberately narrow in scope. It covers your software landscape and nothing else, which is exactly what makes it powerful.&lt;/p&gt;

&lt;p&gt;An IDP becomes the &lt;strong&gt;single source of truth&lt;/strong&gt; for your engineering organization. It answers critical questions across every role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engineers:&lt;/strong&gt; Which services exist, who owns them, what they do, and where the APIs are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team leads and architects:&lt;/strong&gt; Team composition, squad ownership, and architectural decisions with their rationale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New joiners:&lt;/strong&gt; How to get up to speed on a codebase they have never seen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform and operations teams:&lt;/strong&gt; What is running in production, who is responsible, and the lifecycle status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beyond just listing services, a mature IDP centralizes &lt;strong&gt;Architecture Decision Records (ADRs)&lt;/strong&gt;. I'd argue those are more valuable than the service catalog itself, because they capture &lt;em&gt;why&lt;/em&gt; a decision was made. Without a central place to surface them, they end up in forgotten wiki pages or git repositories nobody checks.&lt;/p&gt;

&lt;p&gt;The challenge is keeping all of this populated and accurate. If you rely on engineers to manually maintain metadata YAML files, the data grows stale within weeks. Automation is the only sustainable path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Formulating the Architecture
&lt;/h3&gt;

&lt;p&gt;Here's the plan to extract metadata from source code and present it visually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backstage:&lt;/strong&gt; the UI layer where engineers browse and discover services, APIs, documentation, and team ownership&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;.NET Core:&lt;/strong&gt; a CLI tool that scans project folders, extracts metadata, and generates Backstage-compatible YAML&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama:&lt;/strong&gt; runs AI inference locally. No source code leaves the machine, no API costs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static hosting:&lt;/strong&gt; deploy to Netlify, Azure Static Web Apps, or any provider of your choice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;br&gt;
    &lt;em&gt;Why Ollama?&lt;/em&gt; Because it runs locally and you do not want to expose your code&lt;br&gt;
    to the AI agents over the public internet. You do not know what and how they&lt;br&gt;
    use it for, and it does not feel safe. If your employer finds out, you're&lt;br&gt;
    done.&lt;br&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting Up the Project Infrastructure
&lt;/h3&gt;

&lt;p&gt;You can either follow along and build everything from scratch, or clone the &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;demo repository&lt;/a&gt; to get started immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To clone the demo:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/bgener/demo-backstage-catalog-generator.git
&lt;span class="nb"&gt;cd &lt;/span&gt;demo-backstage-catalog-generator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;To build from scratch&lt;/strong&gt;, start by downloading and running the LLM model we will use in this guide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull llama3:8b
ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;br&gt;
    We use &lt;code&gt;llama3:8b&lt;/code&gt; specifically. It is significantly faster for local&lt;br&gt;
    inference than the full-size model and produces more consistent, concise&lt;br&gt;
    output for our use case. If you have a powerful GPU, feel free to use &lt;code&gt;llama3&lt;/code&gt;&lt;br&gt;
    instead.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;Next, scaffold the baseline .NET services. We’ll create one Web API and one MVC project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;Backstage-Dev-Portal
&lt;span class="nb"&gt;cd &lt;/span&gt;Backstage-Dev-Portal

dotnet new webapi &lt;span class="nt"&gt;-n&lt;/span&gt; ServiceA
dotnet new mvc &lt;span class="nt"&gt;-n&lt;/span&gt; ServiceB

dotnet new sln &lt;span class="nt"&gt;-n&lt;/span&gt; Backstage-Dev-Portal
dotnet sln add ServiceA/ServiceA.csproj
dotnet sln add ServiceB/ServiceB.csproj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can replace the default controllers with real logic later. These raw services represent the uncataloged microservices in your organization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building a Smart Catalog Generator in .NET
&lt;/h3&gt;

&lt;p&gt;We will build a .NET CLI tool using &lt;a href="https://github.com/awaescher/OllamaSharp" rel="noopener noreferrer"&gt;OllamaSharp&lt;/a&gt;. It scans each project, sends relevant files to the local AI model, and generates a single &lt;code&gt;catalog-info.yaml&lt;/code&gt; file containing all services, ready for Backstage to consume.&lt;/p&gt;

&lt;p&gt;Create the tool and add the required package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new console &lt;span class="nt"&gt;-n&lt;/span&gt; ProjectSummarizer
&lt;span class="nb"&gt;cd &lt;/span&gt;ProjectSummarizer
dotnet add package OllamaSharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of sending every file to the AI, we take a smarter approach to avoid token limits and save compute time. We will send only &lt;code&gt;*.csproj&lt;/code&gt;, &lt;code&gt;Program.cs&lt;/code&gt;, and the folder structure. This is all the context the AI needs to understand the project structure and purpose.&lt;/p&gt;

&lt;p&gt;Replace &lt;code&gt;Program.cs&lt;/code&gt; with the implementation below. The full version is in the &lt;a href="https://github.com/bgener/demo-backstage-catalog-generator" rel="noopener noreferrer"&gt;demo repository&lt;/a&gt;. Here we focus on the key parts.&lt;/p&gt;

&lt;p&gt;First, set up the Ollama client and configure the system prompt. This is the most fragile part of the chain: the system prompt has to force the model into a YAML-safe format without it hallucinating markdown backticks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ollamaApiClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OllamaApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:11434"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;SelectedModel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"llama3:8b"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ollamaApiClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s"&gt;"You are a technical documentation assistant. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"You produce concise, YAML-safe summaries of .NET projects. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"Output only plain text, no markdown, no bullet points, no quotes, no colons, no newlines."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of sending every file to the AI, we only send &lt;code&gt;*.csproj&lt;/code&gt;, &lt;code&gt;Program.cs&lt;/code&gt;, and the folder structure. This is all the context the model needs.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
    &lt;strong&gt;Prompt sanitization is critical.&lt;/strong&gt; If your &lt;code&gt;Program.cs&lt;/code&gt; contains complex&lt;br&gt;
    string literals or nested colons, the AI might pass them through to your YAML,&lt;br&gt;
    breaking the Backstage parser. Always sanitize the output before writing the&lt;br&gt;
    file.&lt;br&gt;
&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StringBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Project: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Folder structure:"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;AppendFolderStructure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csprojPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;programPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;projectDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Program.cs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SearchOption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllDirectories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;programPath&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;programPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt itself uses few-shot examples to guide the model toward the output format we want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Summarize the project in 1-2 sentences based on the files provided. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"Do not output anything else. "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"Examples of good output: "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"REST API service providing weather forecasts with temperature data\n"&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
             &lt;span class="s"&gt;"ASP.NET MVC application with React frontend for managing todo items\n\n"&lt;/span&gt;
             &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&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;cts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;summaryBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Append&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;Finally, each summary is sanitized and assembled into a Backstage-compatible YAML entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;summaryBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" -"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"'"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;yamlEntry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$@"&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;apiVersion: backstage.io/v1alpha1&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;kind: Component&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;metadata:&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  name: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLowerInvariant&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  description: ""&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;span class="s"&gt;""&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;spec:&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  type: service&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  lifecycle: production&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="s"&gt;  owner: group:default/engineering"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the generator against the target directory (use &lt;code&gt;.&lt;/code&gt; if you are already in the project root):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; ProjectSummarizer &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the AI streaming its summaries in real time:&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.amazonaws.com%2Fuploads%2Farticles%2Fr8w473r4i4y839n02e56.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.amazonaws.com%2Fuploads%2Farticles%2Fr8w473r4i4y839n02e56.png" alt=" " width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most of the real work here is figuring out the prompt. Even a tiny change can produce a completely different output. I encourage you to experiment with the system prompt and the user prompt to see how it affects quality. That is the real learning here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Integrating the AI Catalog with Backstage
&lt;/h3&gt;

&lt;p&gt;With the &lt;code&gt;catalog-info.yaml&lt;/code&gt; ready, we can integrate it into a Backstage instance. Install Backstage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @backstage/create-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow the prompts to name it &lt;code&gt;dev-portal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now point Backstage to your generated catalog file. Open &lt;code&gt;app-config.yaml&lt;/code&gt; in the &lt;code&gt;dev-portal&lt;/code&gt; directory and add the following under the &lt;code&gt;catalog&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;locations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../Backstage-Dev-Portal/catalog-info.yaml&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Component&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Backstage where to find the AI-generated service metadata. The &lt;code&gt;target&lt;/code&gt; path is relative to the Backstage root directory. Adjust it to point to wherever your generator wrote the &lt;code&gt;catalog-info.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To run it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;dev-portal
yarn dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3000&lt;/code&gt; in your browser. You should see all your services listed in the &lt;strong&gt;Software Catalog&lt;/strong&gt; with AI-generated summaries visible in the description column.&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.amazonaws.com%2Fuploads%2Farticles%2F1gnd3uy6sjadiwto76lv.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.amazonaws.com%2Fuploads%2Farticles%2F1gnd3uy6sjadiwto76lv.png" alt=" " width="799" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploying the Portal
&lt;/h3&gt;

&lt;p&gt;To host this, build your portal as a static site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn build:static
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push the output to GitHub and deploy to any &lt;strong&gt;static hosting provider&lt;/strong&gt;: Netlify, Azure Static Web Apps, Vercel, or even self-hosted on Kubernetes. Set the build command to &lt;code&gt;yarn build:static&lt;/code&gt; and the publish directory to &lt;code&gt;dist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
    A static Backstage build is great for read-only catalogs. If you need dynamic&lt;br&gt;
    features like &lt;strong&gt;authentication&lt;/strong&gt;, &lt;strong&gt;real-time plugin backends&lt;/strong&gt;, or &lt;strong&gt;write&lt;br&gt;
    operations&lt;/strong&gt;, you will need to deploy the full Backstage backend as a Node.js&lt;br&gt;
    service instead.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;The part that is easy to miss is that a static Backstage portal has no live catalog refresh. The catalog is baked at build time, so there is always a lag between a code change and the portal showing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating with CI/CD
&lt;/h2&gt;

&lt;p&gt;The catalog only stays current if the generator runs automatically. Here is a GitHub Actions workflow that regenerates summaries on every push to &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update Backstage Catalog&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generate-catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup .NET&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-dotnet@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;dotnet-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;‘8.0.x’&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install and start Ollama&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -fsSL https://ollama.com/install.sh | sh&lt;/span&gt;
          &lt;span class="s"&gt;ollama serve &amp;amp;&lt;/span&gt;
          &lt;span class="s"&gt;sleep 5&lt;/span&gt;
          &lt;span class="s"&gt;ollama pull llama3:8b&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate catalog&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet run --project ProjectSummarizer -- "$GITHUB_WORKSPACE"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Commit updated catalog&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "github-actions"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "github-actions@github.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add catalog-info.yaml&lt;/span&gt;
          &lt;span class="s"&gt;git diff --cached --quiet || git commit -m "chore: regenerate AI catalog summaries"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;br&gt;
    &lt;strong&gt;CI Performance:&lt;/strong&gt; Running Ollama in CI uses CPU-only inference by default. A&lt;br&gt;
    &lt;code&gt;llama3:8b&lt;/code&gt; summary takes about 20-30 seconds per project on a standard GitHub&lt;br&gt;
    runner. For a large monorepo, your CI bill will spike. Consider using a&lt;br&gt;
    persistent self-hosted runner with a GPU if you scale this.&lt;br&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  Final thoughts
&lt;/h3&gt;

&lt;p&gt;Automate the metadata generation where the code lives, and keep the UI (Backstage) as a thin, static client. That is the whole trick. Once those two are separate, the "stale documentation" problem mostly goes away.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use narrow context:&lt;/strong&gt; Don't send the whole repo. Files like &lt;code&gt;Program.cs&lt;/code&gt; and &lt;code&gt;*.csproj&lt;/code&gt; are usually enough context for the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize strictly:&lt;/strong&gt; AI output is non-deterministic. Always strip colons and newlines before writing to YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start static:&lt;/strong&gt; Read-only is much easier to maintain. Add a backend only when you actually need write operations or auth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would not use this in production unless I had tested the sanitization logic against a few real codebases first. The &lt;code&gt;Replace&lt;/code&gt; calls that strip colons and newlines are intentionally minimal. They break on YAML with complex string values, like connection strings or environment variable blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use OpenAI instead of Ollama?&lt;/strong&gt;&lt;br&gt;
Yes, but you will be sending your source code (or at least your &lt;code&gt;Program.cs&lt;/code&gt;) to a third party. Use a local model if security is a concern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this replace README files?&lt;/strong&gt;&lt;br&gt;
No. It replaces the "Service Directory" that usually lives in a spreadsheet. It points engineers to the README they actually need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle project renames?&lt;/strong&gt;&lt;br&gt;
The generator uses the folder or &lt;code&gt;.csproj&lt;/code&gt; name. If you rename them, Backstage will see it as a new component unless you map the identity stably.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I help teams build exactly this kind of internal tooling, from developer portals to platform engineering. &lt;a href="https://blog.bgener.nl/resume" rel="noopener noreferrer"&gt;See my work&lt;/a&gt; or &lt;a href="https://blog.bgener.nl/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>idp</category>
      <category>dotnet</category>
      <category>devex</category>
    </item>
  </channel>
</rss>
