<?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: Eelco Los</title>
    <description>The latest articles on DEV Community by Eelco Los (@eelcolos).</description>
    <link>https://dev.to/eelcolos</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%2F1407552%2Fa0f81cf1-e1f2-4c20-850b-4b42878ec20f.jpeg</url>
      <title>DEV Community: Eelco Los</title>
      <link>https://dev.to/eelcolos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eelcolos"/>
    <language>en</language>
    <item>
      <title>How I productionized my multi-agent AI support copilot in Teams and Azure</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 29 May 2026 08:05:19 +0000</pubDate>
      <link>https://dev.to/eelcolos/how-i-productionized-my-multi-agent-ai-support-copilot-in-teams-and-azure-4c93</link>
      <guid>https://dev.to/eelcolos/how-i-productionized-my-multi-agent-ai-support-copilot-in-teams-and-azure-4c93</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Built a &lt;a href="https://github.com/EelcoLos/nx-tinkering/tree/main/apps/a2a-docker-demo" rel="noopener noreferrer"&gt;.NET A2A demo&lt;/a&gt; to validate the triage pattern before deploying the Python system. If the shape only works in one stack, it is not a portable pattern.&lt;/li&gt;
&lt;li&gt;Teams is the ingest channel for this deployment, not a hard requirement. The bot posts to a channel-agnostic &lt;code&gt;/ingress&lt;/code&gt; endpoint; any other ingest can do the same.&lt;/li&gt;
&lt;li&gt;Teams timeout budgets forced a full async reply architecture.&lt;/li&gt;
&lt;li&gt;Adaptive card size limits forced progressive disclosure: compact badge up front, everything else behind toggles.&lt;/li&gt;
&lt;li&gt;RSC permissions only activate on manifest install, not Entra consent alone. Getting that order wrong costs you a 403 and a bug you cannot reproduce.&lt;/li&gt;
&lt;li&gt;Containerization was table stakes. The real work was auth, telemetry, storage, and making every platform permission explicit.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Part 2 covered the runtime failures and the hardening work that followed. This post is about the next step: productionization.&lt;/p&gt;

&lt;p&gt;Once the system was capable of producing credible triage results repeatedly, the question changed again. It was no longer "can this architecture work?" It was "can this behave like a deployed product?"&lt;/p&gt;

&lt;p&gt;That question turned out to be broader than "put it in Docker":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;channels had timeout budgets&lt;/li&gt;
&lt;li&gt;Teams cards had presentation limits&lt;/li&gt;
&lt;li&gt;attachments arrived in platform-specific shapes&lt;/li&gt;
&lt;li&gt;storage and audit needed durable homes&lt;/li&gt;
&lt;li&gt;deployment needed images, identities, secrets, probes, and update flow&lt;/li&gt;
&lt;li&gt;admin approval and manifest install were part of the runtime story, not just setup trivia&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words: the hard part was not just getting the system to reason. It was getting the system to operate in the real environment it was supposed to serve. For this deployment, that environment is Microsoft Teams as the ingest channel and Azure as the runtime.&lt;/p&gt;




&lt;h2&gt;
  
  
  Validating the shape before leaning on it
&lt;/h2&gt;

&lt;p&gt;Before going further into the productionization lessons, I want to point to something concrete.&lt;/p&gt;

&lt;p&gt;When agents run inside an LLM session, it is hard to tell whether a failure is a routing problem or a model problem. Before deploying, I wanted proof that the triage pattern held up in a more traditional distributed system: one where the "agents" are plain HTTP services, not LLM sub-sessions, and the failures are just HTTP failures.&lt;/p&gt;

&lt;p&gt;The main system uses Claude Code as the multi-agent runtime, so the orchestration is intertwined with model behavior. A standalone implementation in a different stack could isolate the protocol from the reasoning and give a cleaner signal: does the triage shape work, or does it only work because the LLM is papering over gaps?&lt;/p&gt;

&lt;p&gt;That's why I built &lt;a href="https://github.com/EelcoLos/nx-tinkering/tree/main/apps/a2a-docker-demo" rel="noopener noreferrer"&gt;a2a-docker-demo&lt;/a&gt;: a standalone FastEndpoints/.NET implementation of the same triage pattern, using the A2A protocol spec. The main system is Python, so using a different stack for the demo was deliberate. If the shape only works in one language or one framework, it's not really a portable pattern.&lt;/p&gt;

&lt;p&gt;The demo runs a full triage workflow across five services: Classifier, Assessor, Router, Handler, and an API backend that orchestrates them. Each service does one narrow job and knows nothing about the others. The API backend sequences them. In plain terms: a request comes in, gets classified, gets assessed for priority, gets routed to a queue, and gets handled. The A2A protocol is what connects them: each specialist advertises what it can do via a machine-readable card, and callers use that card to invoke it over a standard JSON-RPC endpoint. Authentication uses short-lived tokens tied to the calling agent's identity, not shared secrets, so every service-to-service call is independently verifiable. Grafana and Tempo make the call graph visible.&lt;/p&gt;

&lt;p&gt;What it confirmed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The linear triage shape works: each specialist receives a request, does its narrow job, and returns a structured result. No specialist needs to know about the others.&lt;/li&gt;
&lt;li&gt;Identity at the boundary is not optional. Mixing user tokens and agent tokens causes 401s in ways that are easy to miss if you're not thinking about it.&lt;/li&gt;
&lt;li&gt;Discovery is its own concern. The demo includes a discovery service, but the active triage flow ended up not depending on dynamic discovery. The API backend knows its specialists and fetches their agent cards directly. That turned out to be the right tradeoff for a known-topology system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the LLM layer adds is the reasoning that rule-based routing can't replicate. The demo classifies, routes, and handles, but everything it knows is hardcoded. The main system's value is that the evidence agents read real telemetry, CRM data, provisioning logs, and identity policy, and the synthesis layer reasons across them. That's not something you can validate in a protocol demo. But the protocol demo proved the container shape, the auth boundaries, and the A2A communication pattern before I had to debug all three at once inside a production deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Teams, and why it matters that it's just an ingest
&lt;/h2&gt;

&lt;p&gt;New support tickets arrive in Teams via an automated workflow from the ticketing system. Support agents were already working in that channel. Rather than build a new interface and ask people to change their flow, we injected the triage bot into the channel where the work was already happening. The bot intercepts the incoming workflow messages and runs triage in parallel, without requiring any change to the existing process.&lt;/p&gt;

&lt;p&gt;The architecture reflects that the Teams ingest is not load-bearing. A thin bot adapter receives the message and forwards it to a channel-agnostic &lt;code&gt;/ingress&lt;/code&gt; endpoint on the orchestration container. Any other ingest (a Zendesk webhook, an email parser, a direct API call) can POST to that same endpoint without touching the orchestration layer. The bot is pluggable. The core system does not care where the message came from.&lt;/p&gt;

&lt;p&gt;What we did not fully anticipate was how much the &lt;em&gt;properties of that specific ingest channel&lt;/em&gt; would shape the surrounding architecture. The next three lessons are all consequences of that choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 8: async boundaries became part of the product
&lt;/h2&gt;

&lt;p&gt;The original mental model was synchronous: a Teams message comes in, the bot forwards it, the management agent does its work, and the reply comes back in the thread.&lt;/p&gt;

&lt;p&gt;That flow is elegant and wrong.&lt;/p&gt;

&lt;p&gt;A real triage takes minutes. Teams does not care that your orchestration is elegant. The Bot Framework wants a quick response. The channel wants acknowledgment fast. If you wait for full triage before responding, you have already lost. The request times out before the result arrives.&lt;/p&gt;

&lt;p&gt;That forced an architectural change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Teams message
  -&amp;gt; immediate acknowledgment ("🔍 Triage in progress...")
  -&amp;gt; POST /ingress  →  HTTP 202 Accepted
  -&amp;gt; background triage task
  -&amp;gt; POST callback_url  (from orchestrator back to bot)
  -&amp;gt; adapter.continue_conversation()
  -&amp;gt; threaded Teams reply with result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo reflects that shift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app.py&lt;/code&gt; accepts ingress and returns &lt;code&gt;202 Accepted&lt;/code&gt;, then fires &lt;code&gt;asyncio.create_task(run_triage_background(...))&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;triage runs in the background against the LLM provider&lt;/li&gt;
&lt;li&gt;the result is POSTed to the bot's &lt;code&gt;/api/proactive&lt;/code&gt; endpoint, secured by a shared key in Key Vault&lt;/li&gt;
&lt;li&gt;the Teams bot posts back into the original thread using the stored &lt;code&gt;ConversationReference&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is one of those moments where "plumbing" turns into product behavior. The async boundary is not an implementation detail. It determines whether the user experiences the system as responsive or broken.&lt;/p&gt;

&lt;p&gt;It also pushed the architecture toward a clearer separation of concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ingress&lt;/strong&gt;: receive, acknowledge, store the conversation reference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;orchestration&lt;/strong&gt;: run triage in the background&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;presentation&lt;/strong&gt;: post back via proactive callback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once a channel has timeout budgets, async is not an optimization. It is table stakes. A different ingest channel would impose different constraints, but the same shape holds regardless: acknowledge fast, process in the background, deliver the result asynchronously.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 9: presentation constraints changed the architecture too
&lt;/h2&gt;

&lt;p&gt;Another production surprise: the output surface is part of the system design.&lt;/p&gt;

&lt;p&gt;In local markdown reports, it is easy to think "just show all the evidence." In Teams Adaptive Cards, that becomes nonsense very quickly.&lt;/p&gt;

&lt;p&gt;The card payload has practical size limits. Evidence can be huge. Customer drafts can be long. Raw APM output can explode in size. A triage system that preserves everything internally still has to decide what a human should see at a glance.&lt;/p&gt;

&lt;p&gt;That's why the card formatter ended up with explicit progressive disclosure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;collapsible sections&lt;/li&gt;
&lt;li&gt;capped evidence claims&lt;/li&gt;
&lt;li&gt;capped claim lengths&lt;/li&gt;
&lt;li&gt;inline draft only when short enough&lt;/li&gt;
&lt;li&gt;details hidden by default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But even that wasn't enough once real users saw it. The initial cards were simply too large: multiple agents each rendering up to 20 claims at 300 characters apiece added up to cards that were difficult to scan. The fix was a compact outer container (a title, a two-field confidence badge, and a single "Show analysis" toggle) with everything else collapsed until the support agent asks for it.&lt;/p&gt;

&lt;p&gt;That was not just UI cleanup. It was a change in how the system expressed itself. The best pattern I found:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep the top-level card short and scannable. One glance should answer "is this worth expanding?"&lt;/li&gt;
&lt;li&gt;Preserve deeper detail behind toggles&lt;/li&gt;
&lt;li&gt;Store the raw material in blob storage, not in the card&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That matches the broader architecture: compact context for humans, raw evidence for audit, and only the right subset flowing into the model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 10: your ingest channel owns its own permission surface
&lt;/h2&gt;

&lt;p&gt;Attachments and platform permissions turned into their own evidence-delivery problem, and a reminder that the ingest layer is not passive.&lt;/p&gt;

&lt;p&gt;The plans already recognized that screenshots, logs, exports, and shared-file URLs were part of the incident surface. But the runtime taught a more specific lesson: the platform decides how those inputs arrive, and that shape is often inconvenient.&lt;/p&gt;

&lt;p&gt;Teams inline images are one good example. They are not always delivered as clean attachments in the way you might expect. Pasted or inline images can show up only as hosted-content URLs embedded in the HTML body. If your system only looks at the attachment list, you miss them. That is why the Teams bot needed logic to inspect HTML body content, extract hosted-content image URLs, use the bot token to fetch Teams-hosted files, and normalize those results into the incident attachment pipeline.&lt;/p&gt;

&lt;p&gt;The same pattern repeated for file access. The bot could parse file URLs from the message body, but actually fetching team-shared files required &lt;code&gt;Files.Read.Group&lt;/code&gt;, a separate RSC permission declared in the manifest. Another capability, another permission, another manifest version, another install cycle with the Teams admin.&lt;/p&gt;

&lt;p&gt;But the deeper platform lesson came from a different direction: &lt;strong&gt;permissions that look granted are not always active&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Getting the bot working in a real Teams tenant required two things in combination: Entra admin consent &lt;em&gt;and&lt;/em&gt; installing the Teams app manifest. That sounds obvious, but the order of operations matters. Entra consent registers the app and grants the declared permissions in principle. RSC permissions, like &lt;code&gt;ChannelMessage.Read.Group&lt;/code&gt; (which the bot needs to read thread history), are only activated when the manifest is installed. Entra approval alone does not trigger them. The manifest install is what causes the Teams platform to enforce and expose the RSC surface.&lt;/p&gt;

&lt;p&gt;We discovered this the hard way. The bot could post replies. Entra showed the app as approved. But every attempt to fetch a thread's root message via the Graph API returned 403. The permission looked granted. The permission was not active.&lt;/p&gt;

&lt;p&gt;Once RSC was properly activated, a second issue surfaced: the bot had been falling back to stale context when it couldn't read the thread root. A user mentioning the bot in a reply to a workflow card would get triage results for the &lt;em&gt;wrong incident&lt;/em&gt;, because the bot resolved incident identity from prior stored state instead of the thread's origin message. The 403 had masked the bug. The code had been correct for some time, but it could only prove it once the permission was actually live.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of thing you only learn by running the system for real. The code can be correct. The bot can be deployed. The Azure side can be healthy. And the feature can still fail because the host platform has not activated the permission surface your runtime depends on.&lt;/p&gt;

&lt;p&gt;So yes: the Teams IT-admin path is part of the architecture. Entra approval, app manifest, RSC permission activation, installation flow, and actual tenant behavior are operational dependencies, not external trivia.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 11: containerization is only one slice of productionization
&lt;/h2&gt;

&lt;p&gt;By this phase, the repo had clearly crossed the line from "research notes plus local prompts" into "things that get packaged and deployed."&lt;/p&gt;

&lt;p&gt;There are now two main runtime packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the orchestrator/ingress app&lt;/li&gt;
&lt;li&gt;the Teams bot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They have Dockerfiles. They are built and pushed to ACR. They are rolled out to Azure Container Apps. They expose &lt;code&gt;/health&lt;/code&gt;. They carry shared secrets and callback URLs. They log. They emit telemetry. They get updated as separate containers.&lt;/p&gt;

&lt;p&gt;That is containerization.&lt;/p&gt;

&lt;p&gt;But the bigger lesson is that containerization was only the beginning. Productionization also meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;provider abstraction&lt;/strong&gt;: the runtime could not stay hardwired to one LLM access path, so the plans moved toward a BYOK provider layer (&lt;code&gt;azure&lt;/code&gt;, &lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;github&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;identity model&lt;/strong&gt;: local CLI auth had to give way to managed identity and workload identity planning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deployment ergonomics&lt;/strong&gt;: separate images, separate updates, named container updates, cross-subscription ACR login&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;telemetry&lt;/strong&gt;: OpenTelemetry spans and structured logs had to exist so failures were diagnosable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;secrets and hooks&lt;/strong&gt;: webhook secrets and proactive callback tokens had to be validated explicitly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;storage&lt;/strong&gt;: incident state and raw evidence needed durable homes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even the Docker image contents taught something. Installing Azure CLI and GitHub CLI inside the root image made the local and hosted behavior more consistent, but it also made the image heavier and startup slower. That is not a reason not to do it. It is a reminder that packaging decisions become runtime tradeoffs.&lt;/p&gt;

&lt;p&gt;So when I say "productionization," I do include containerization. I just do not mean only containerization.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Azure object model had to become explicit too
&lt;/h2&gt;

&lt;p&gt;Another thing I underestimated was how much clarity you get from naming the Azure objects explicitly.&lt;/p&gt;

&lt;p&gt;When a system is still mostly a notebook idea or a local Docker flow, it is easy to say "we'll deploy this to Azure" and leave the rest fuzzy. But productionization forces a sharper question: &lt;strong&gt;what are the actual Azure resource types this system needs, and what job does each one do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is what is currently deployed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Azure object&lt;/th&gt;
&lt;th&gt;Why it exists&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resource Group&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The boundary that holds the deployed environment together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container Registry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stores the bot image and the orchestrator/ingress image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container Apps Environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Shared runtime boundary for the deployed containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container App: orchestrator/ingress&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Receives requests, runs triage, handles callbacks and orchestration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container App: Teams bot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Handles Teams messages, threaded replies, proactive updates, and attachment fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User-assigned Managed Identity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gives the runtime a stable Azure identity without interactive login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key Vault&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Holds secrets that still need to exist: bot credentials, shared callback keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage Account + Blob containers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incident state, attachments, and raw evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Application Insights + Log Analytics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Telemetry, traces, and operational debugging&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Azure Bot / Entra app registration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The identity and control-plane pieces that let the Teams bot exist in the tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RBAC role assignments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read/write boundaries: which identity can access telemetry, storage, and queues&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Naming the objects helped in two ways. First, it made the platform shape legible: instead of "an AI agent deployed to Azure," you get a concrete service map. Second, it surfaced hidden dependencies early. If you want auditability, you need durable storage beyond the model context. If you want a Teams bot to work in a real tenant, app registration and manifest install are not optional side notes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the plans and sessions converged on
&lt;/h2&gt;

&lt;p&gt;Looking back across the design notes, the implementation plans, and the build sessions, the lessons converge pretty cleanly.&lt;/p&gt;

&lt;p&gt;The architecture itself was mostly right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;orchestrator-worker&lt;/li&gt;
&lt;li&gt;blackboard-style shared incident context&lt;/li&gt;
&lt;li&gt;no agent-to-agent communication&lt;/li&gt;
&lt;li&gt;deterministic policy gate&lt;/li&gt;
&lt;li&gt;human approval before external action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What needed the most work was everything around that architecture: auth, storage, async handoff, presentation, deployment, and the platform admin paths that only matter once you leave the notebook.&lt;/p&gt;

&lt;p&gt;That is why the project stopped feeling like prompt engineering and started feeling like systems engineering.&lt;/p&gt;

&lt;p&gt;The multi-agent part did not go away. It just became one layer inside a broader operational stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  What productionization actually meant here
&lt;/h2&gt;

&lt;p&gt;If I had to compress the whole phase into one sentence, it would be this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Productionization was not "put the prototype in Docker." It was making every important boundary explicit.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Local auth versus hosted identity. Compact model context versus full raw evidence. Synchronous intake versus async completion. Code correctness versus platform permission activation. Each of those had to become an explicit seam in the system, not an implicit assumption.&lt;/p&gt;

&lt;p&gt;That is the part I underestimated most.&lt;/p&gt;

&lt;p&gt;The architecture survived. The work was in making it durable, inspectable, resumable, deployable, and permissioned enough to live outside the notebook.&lt;/p&gt;

&lt;p&gt;And that, more than any single prompt or agent definition, is what made it start to feel real.&lt;/p&gt;

&lt;p&gt;Part 4 will cover what actually running this in production taught us: the quiet failures, the behaviors we didn't predict, and the feedback loops that changed the second iteration.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>teams</category>
      <category>azure</category>
    </item>
    <item>
      <title>How I hardened my multi-agent AI support copilot</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 24 Apr 2026 14:56:25 +0000</pubDate>
      <link>https://dev.to/eelcolos/how-i-hardened-my-multi-agent-ai-support-copilot-15p7</link>
      <guid>https://dev.to/eelcolos/how-i-hardened-my-multi-agent-ai-support-copilot-15p7</guid>
      <description>&lt;p&gt;The first post in this series was about the design. This one is about what happened when the first real tickets hit the wiring, and about the hardening work that followed once those runs exposed the weak spots.&lt;/p&gt;

&lt;p&gt;The good news is that the architecture mostly held up. The orchestrator-worker model was still the right shape. Parallel evidence gathering still made sense. Shared incident context still made sense. Human approval still made sense.&lt;/p&gt;

&lt;p&gt;What broke was the boundary layer: what actually executes a skill, how incomplete configuration should fail, what a spawned sub-agent can really invoke, which tools are scoped to the parent session instead of the child, how incident state should persist, and how much of the system could be tested like normal software.&lt;/p&gt;

&lt;p&gt;The examples here are specific to Claude Code, but the class of problem is broader than one tool. In multi-agent systems, runtime semantics matter more than diagrams.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 1: Skills are documents. Agents are executors.
&lt;/h2&gt;

&lt;p&gt;The first live test was against a real Zendesk ticket from a customer tenant: "The request is blocked" during provisioning admin consent. The Management Agent ran. Sub-agents were dispatched. Evidence came back.&lt;/p&gt;

&lt;p&gt;But something was wrong: no &lt;code&gt;az&lt;/code&gt; commands had actually run. No APIs were called. The "evidence" was the agents describing what the skills &lt;em&gt;would&lt;/em&gt; do if they were run: accurate, thoughtful, and completely useless.&lt;/p&gt;

&lt;p&gt;The root cause took a minute to understand. I had treated the skill files as if they were runnable workers. They weren't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SKILL.md&lt;/code&gt; files in Claude Code are &lt;strong&gt;execution instructions for an agent that already has the right tools&lt;/strong&gt;. The frontmatter declares &lt;code&gt;allowed-tools: Bash(az *)&lt;/code&gt;, but that only scopes permissions when the skill is directly invoked as a user command. In this repo, I call the task-spawned executors evidence agents. The &lt;code&gt;.claude/agents/*.md&lt;/code&gt; files define which one runs, what tools it gets, and that it should execute the skill's commands rather than just read the skill file. When the Management Agent does &lt;code&gt;TaskCreate -&amp;gt; "run the SCIM skill"&lt;/code&gt;, it spawns one of those evidence agents. That evidence agent needs its own &lt;code&gt;.md&lt;/code&gt; definition that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Declares it has &lt;code&gt;Bash(az *)&lt;/code&gt; in its &lt;code&gt;tools:&lt;/code&gt; frontmatter&lt;/li&gt;
&lt;li&gt;Has a system prompt that says &lt;em&gt;execute the skill's commands&lt;/em&gt;, not just &lt;em&gt;read the skill file&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We had written the instructions, but not the workers. The &lt;code&gt;.claude/agents/&lt;/code&gt; directory was mostly empty. There was nothing for &lt;code&gt;TaskCreate&lt;/code&gt; to dispatch to by name.&lt;/p&gt;

&lt;p&gt;The fix was straightforward once the problem was clear: create &lt;code&gt;.claude/agents/apm.md&lt;/code&gt;, &lt;code&gt;crm.md&lt;/code&gt;, &lt;code&gt;scim.md&lt;/code&gt;, &lt;code&gt;b2c.md&lt;/code&gt;, &lt;code&gt;alm.md&lt;/code&gt;. Each with:&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="nn"&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;APM Evidence Agent&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="s"&gt;Evidence-gathering sub-agent for application telemetry. Reads the APM skill&lt;/span&gt;
  &lt;span class="s"&gt;instructions, executes the query steps, and returns structured claim[] entries.&lt;/span&gt;
  &lt;span class="s"&gt;Dispatched by the Management Agent via TaskCreate; not user-invocable directly.&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;claude-sonnet-4.5&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bash(az *)&lt;/span&gt;
&lt;span class="na"&gt;maxTurns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="s"&gt;You are the APM Evidence Agent. Given an IncidentContext with resolved_identity,&lt;/span&gt;
&lt;span class="s"&gt;execute the APM skill bootstrap and query steps. Write structured claim[] entries&lt;/span&gt;
&lt;span class="s"&gt;to IncidentContext.evidence[]. Return a compact summary to the Management Agent.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The takeaway&lt;/strong&gt;: &lt;code&gt;SKILL.md&lt;/code&gt; is documentation. An agent definition is what actually runs. Don't confuse the map for the territory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 2: Fail fast on configuration. Silent dry-run is an anti-pattern.
&lt;/h2&gt;

&lt;p&gt;The second live test exposed a different problem. All four evidence agents returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"claim_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gap"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dry_run"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"claim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Required config fields empty: key_resources[].name, resource_group, subscription_id"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same from CRM, provisioning, and identity config. The Management Agent continued, the Synthesis Agent synthesised from nothing. We got a hypothesis based purely on domain pattern-matching, with zero real telemetry. And we'd burned a full round of context tokens to get there.&lt;/p&gt;

&lt;p&gt;The worst part was not that evidence was missing. It was that the output still looked plausible. A clean failure would have been better than a confident-looking answer built on gaps.&lt;/p&gt;

&lt;p&gt;The design intent was different. The &lt;code&gt;--dry-run&lt;/code&gt; flag existed for demos and training: an opt-in mode. But in practice, any time config was incomplete, the system silently fell back to dry-run instead of reporting what was missing.&lt;/p&gt;

&lt;p&gt;The fix: a &lt;code&gt;validate-expertise&lt;/code&gt; skill that runs at session start, checks all required fields across the five domain YAML files, and reports what's missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Domain   | Field                     | Status  | Auto-discovery
---------|---------------------------|---------|------------------------------------------------
apm      | key_resources[0].name     | ❌ EMPTY | az monitor app-insights component list -o table
crm      | base_url.prod             | ❌ EMPTY | (manual; check deployment docs)
scim     | scim_enterprise_app_sp_id | ❌ EMPTY | az rest ... /servicePrincipals?$filter=...
identity | identity_tenant_id        | ❌ EMPTY | az ad tenant list
alm      | key_repos[0].owner        | ❌ EMPTY | gh repo list --json owner,name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any required field is empty → print the fill table, log to audit, &lt;strong&gt;stop&lt;/strong&gt;. No evidence agents dispatch.&lt;/p&gt;

&lt;p&gt;We also annotated the YAML files directly:&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;# apm.yaml&lt;/span&gt;
&lt;span class="na"&gt;key_resources&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;              &lt;span class="c1"&gt;# REQUIRED: az monitor app-insights component list -o table&lt;/span&gt;
    &lt;span class="na"&gt;resource_group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# REQUIRED: same command&lt;/span&gt;
    &lt;span class="na"&gt;subscription_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# REQUIRED: az account show --query id -o tsv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The meta-lesson: dry-run mode should be opt-in and explicit, not the default fallback when setup is incomplete. If the system silently degrades, you don't know it's wrong until you compare the output to reality.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 3: Slash commands don't exist in sub-agents
&lt;/h2&gt;

&lt;p&gt;This one was embarrassing.&lt;/p&gt;

&lt;p&gt;The Management Agent's Step 0 contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/validate-expertise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Onboarding flow contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/validate-expertise &lt;span class="nt"&gt;--dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of these were silently not working. The agent treated them as skill tool calls, got "skill not found" errors, and then manually read the YAML files anyway, which produced a plausible output that masked the actual failure.&lt;/p&gt;

&lt;p&gt;Skills in Claude Code are not registered slash commands. They are markdown files. The correct invocation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read .claude/skills/validate-expertise/SKILL.md and execute the validation steps.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every place in agent instructions that used &lt;code&gt;/skill-name&lt;/code&gt; syntax needed updating to explicit file-read instructions. The &lt;code&gt;prime&lt;/code&gt; skill had the right pattern all along: "Load current context: execute the prime skill by reading &lt;code&gt;.claude/skills/prime/SKILL.md&lt;/code&gt;". We just hadn't applied it consistently.&lt;/p&gt;

&lt;p&gt;The fix (pr-13) also introduced an &lt;strong&gt;Onboarding Agent&lt;/strong&gt;, a dedicated agent that runs &lt;code&gt;az&lt;/code&gt;/&lt;code&gt;gh&lt;/code&gt; auto-discovery commands for each domain, presents the discovered values interactively, and writes confirmed non-secret values into the YAML files. After it runs, it re-validates. The support engineer goes from "11 empty required fields" to "ready to triage" in one guided session.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 4: Constrain the interface, not just the credentials
&lt;/h2&gt;

&lt;p&gt;This one came in two layers. The CRM skill was the first fully working skill. It uses an MCP server (&lt;code&gt;CRM-*&lt;/code&gt; tools) that wraps an internal customer-data API. The API key behind it had write access across the CRM surface, so I intentionally exposed only the read endpoints through the MCP layer. That kept the agent's direct permissions narrow.&lt;/p&gt;

&lt;p&gt;The deeper boundary showed up when we wired that same capability into a &lt;code&gt;TaskCreate -&amp;gt; "CRM Evidence Agent"&lt;/code&gt; dispatch. The sub-agent started and then hit &lt;code&gt;AADSTS65001&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AADSTS65001: The user or administrator has not consented to use the application
with ID '{ResourceId}'...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP tools (&lt;code&gt;CRM-get_company&lt;/code&gt;, &lt;code&gt;CRM-lookup_user_by_email&lt;/code&gt;, etc.) are session-scoped to the Management Agent's Claude Code session. When a sub-agent is spawned via &lt;code&gt;task&lt;/code&gt;, it starts with a clean tool set; MCP tools don't carry over. The lesson was not just "use read-only endpoints" but also "don't assume the child session has the parent's tools." The sub-agent fell back to &lt;code&gt;az account get-access-token --resource {ResourceId}&lt;/code&gt;, which fails because that service principal hasn't been granted consent for interactive CLI flows.&lt;/p&gt;

&lt;p&gt;The workaround (and current design): &lt;strong&gt;CRM runs as a direct call in the Management Agent session&lt;/strong&gt;, not as a TaskCreate dispatch. The Management Agent calls &lt;code&gt;CRM-lookup_user_by_email&lt;/code&gt; directly before dispatching other evidence agents. Identity resolution happens first, synchronously, in the parent session.&lt;/p&gt;

&lt;p&gt;Worth separating two things here that are easy to conflate. The direct-call pattern is a &lt;em&gt;workaround&lt;/em&gt; for the MCP tool-scope limitation. But it may also be the &lt;em&gt;right shape&lt;/em&gt; regardless. Resolving identity synchronously in the parent session, before dispatching parallel workers, is a reasonable sequencing decision on its own terms: it keeps identity resolution out of the sub-agents, surfaces auth failures early, and means workers can assume a resolved identity rather than having to negotiate it. When MCP tool forwarding lands, the question isn't automatically "switch back to the dispatch model." It's "is synchronous identity resolution still the better shape?" The workaround and the correct architecture might happen to be the same thing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;decisions.md&lt;/code&gt; entry captures the distinction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Current state: MCP tool forwarding in &lt;code&gt;task&lt;/code&gt;: Not supported.&lt;br&gt;
When to re-evaluate: New Claude Code version / MCP spec update.&lt;br&gt;
When available: CRM can be dispatched as &lt;code&gt;TaskCreate → "CRM Evidence Agent"&lt;/code&gt; with &lt;code&gt;CRM-*&lt;/code&gt; tools. Remove direct-call pattern from management-agent.md.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Document the current workaround, document the future path, document the trigger for re-evaluation. That's the pattern for any runtime constraint you're working around rather than solving.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 5: local-dev auth and production auth are different systems
&lt;/h2&gt;

&lt;p&gt;One of the first hardening lessons was that I had been mentally treating authentication as one thing. It wasn't.&lt;/p&gt;

&lt;p&gt;In the research phase, the working assumption was simple: if &lt;code&gt;az login&lt;/code&gt; works in the parent session, the sub-agents should be able to use the same credentials. Real runs made that assumption look shaky. We saw cases where the APM evidence path returned a credential gap even though the parent session was already authenticated.&lt;/p&gt;

&lt;p&gt;That does &lt;strong&gt;not&lt;/strong&gt; automatically mean the agent logic was wrong. It means the runtime boundary was more important than the prompt. A child session is not the same thing as the parent. A local CLI flow is not the same thing as a hosted workload identity. "Authenticated on my machine" is not a production auth strategy.&lt;/p&gt;

&lt;p&gt;For local development, we found one concrete pattern that was reliable: user-level environment variables were visible to new PowerShell sessions, while process-level &lt;code&gt;$env:&lt;/code&gt; values were not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Set once for local dev:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;SetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AZURE_DEVOPS_EXT_PAT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$yourToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'User'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Read inside a spawned session:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AZURE_DEVOPS_EXT_PAT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'User'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That solved a local dev problem. It did not solve the production problem.&lt;/p&gt;

&lt;p&gt;The broader lesson was that auth should be treated as part of the architecture, not as bootstrap glue. If the system depends on interactive CLI auth in production, it is not productionized yet.&lt;/p&gt;

&lt;p&gt;The production direction is a workload identity model: managed identity on the host, workload identity federation for cross-tenant scenarios, service principals with explicit RBAC rather than user delegation. How that maps to this system's deployment shape is Part 3 territory. The design implication was already clear in Part 2: auth is an architectural decision, not something you patch in at the prompt layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 6: IncidentContext needed to be durable and layered
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;IncidentContext&lt;/code&gt; pattern held up extremely well. It was still the right abstraction: every evidence agent writes into shared incident state, the synthesis layer reads from it, and agents do not talk directly to one another.&lt;/p&gt;

&lt;p&gt;But the storage story behind that abstraction had to evolve.&lt;/p&gt;

&lt;p&gt;At first, &lt;code&gt;IncidentContext&lt;/code&gt; was mostly conceptual. It lived in prompts, in in-memory state, and in repeated task payloads. That was enough to prove the architecture, but it broke down as soon as the system had to resume work, survive interruptions, or preserve a real audit trail.&lt;/p&gt;

&lt;p&gt;The first hardening step was to make it a real file. That solved two immediate problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Token overhead&lt;/strong&gt;: we stopped re-injecting the full incident state into every sub-agent prompt&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resume&lt;/strong&gt;: the incident could survive beyond one chat turn or one session&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But even at that stage, the more general lesson was clear: not all state belongs in the model context, and not all state should stay implicit.&lt;/p&gt;

&lt;p&gt;Once incidents become long-lived objects rather than prompt-shaped blobs, you can start talking seriously about revisions, late evidence, replay, and audit continuity.&lt;/p&gt;

&lt;p&gt;That's what "layered" means here. An incident has distinct phases: intake state, live evidence under collection, synthesized hypothesis, reviewer annotations, and final audit record. Those phases have different lifecycles and different consumers. Flattening them into a single blob works fine when an incident is simple and completes in one run. It breaks when evidence arrives late, when the system is interrupted mid-investigation, or when you need to replay synthesis without re-running the full evidence pass. The file-backed &lt;code&gt;IncidentContext&lt;/code&gt; was the first step toward treating those phases as first-class state, not just named sections of a big prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 7: you can test more of this than you think
&lt;/h2&gt;

&lt;p&gt;One of the most useful hardening lessons in the repo was how much of a multi-agent system is actually testable with normal engineering techniques.&lt;/p&gt;

&lt;p&gt;At first, it is tempting to think of an agent system as mostly prompt behavior, and therefore mostly manual validation. That does not scale for long.&lt;/p&gt;

&lt;p&gt;The breakthrough was to stop thinking about the whole thing as one fuzzy AI system and start testing its surfaces separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Static validation tests
&lt;/h3&gt;

&lt;p&gt;These do not touch an LLM at all. They just assert that the repo is internally coherent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;expertise YAML files contain the required fields&lt;/li&gt;
&lt;li&gt;agent and skill markdown files have valid frontmatter&lt;/li&gt;
&lt;li&gt;incident context JSON files match schema&lt;/li&gt;
&lt;li&gt;claim objects have the required fields and valid confidence ranges&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That catches a surprising amount of breakage before you ever run a live incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Deterministic logic tests
&lt;/h3&gt;

&lt;p&gt;Some of the system is not AI behavior. It is just logic.&lt;/p&gt;

&lt;p&gt;We wrote tests for things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;synthesis confidence scoring logic&lt;/li&gt;
&lt;li&gt;policy gate thresholds&lt;/li&gt;
&lt;li&gt;intake regexes such as &lt;code&gt;regression_from_version&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;recalibration boundaries like &lt;code&gt;[0.45, 0.65)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are product decisions encoded in code. They deserve ordinary tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Contract tests for agents
&lt;/h3&gt;

&lt;p&gt;Because so much of the runtime lives in markdown, agent files and skill files need to be treated as contracts, not prose.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every documented agent must have a corresponding &lt;code&gt;.md&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;every evidence agent must allow the claim types it actually emits&lt;/li&gt;
&lt;li&gt;dry-run gap claims must have &lt;code&gt;confidence = 0.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;routing rules stay consistent with the management agent documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If markdown is part of the runtime, markdown deserves tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Golden cases from real incidents
&lt;/h3&gt;

&lt;p&gt;Past incidents became fixtures. We asserted things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at least four evidence agents contributed&lt;/li&gt;
&lt;li&gt;the top hypothesis stays within a target confidence range&lt;/li&gt;
&lt;li&gt;no PII leaks into hypothesis text&lt;/li&gt;
&lt;li&gt;audit steps appear in the right order&lt;/li&gt;
&lt;li&gt;known incidents still produce the same class of answer after refactors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That turns solved incidents into reusable engineering assets.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. LLM evals where model behavior really matters
&lt;/h3&gt;

&lt;p&gt;There is still a model-shaped layer, especially around synthesis quality, reviewer behavior, and policy gate quality. That is where evals belong.&lt;/p&gt;

&lt;p&gt;The repo now includes evals for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;synthesis quality&lt;/li&gt;
&lt;li&gt;reviewer PII and promise detection&lt;/li&gt;
&lt;li&gt;policy gate correctness&lt;/li&gt;
&lt;li&gt;incident epistemic safety&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the tests are not just local rituals anymore. They are wired into CI, so pushes and pull requests get automated results instead of "I tried a few prompts and it seemed okay."&lt;/p&gt;

&lt;p&gt;The broader lesson is simple: multi-agent systems are still software. Test the deterministic parts deterministically. Test contracts as contracts. Use golden cases for regressions. Save LLM evals for the surfaces that actually require model judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  What these failures had in common
&lt;/h2&gt;

&lt;p&gt;None of these were "the model reasoned badly" problems. They were places where I had relied on an implicit contract that wasn't actually real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I treated instructions as executors&lt;/li&gt;
&lt;li&gt;I treated missing config as something the system could gracefully work around&lt;/li&gt;
&lt;li&gt;I treated slash-command syntax as if it were a portable invocation layer&lt;/li&gt;
&lt;li&gt;I treated parent-session tools as if they would be inherited by child sessions&lt;/li&gt;
&lt;li&gt;I treated local auth as if it would behave the same across runtime contexts&lt;/li&gt;
&lt;li&gt;I treated prompt memory as if it were durable system state&lt;/li&gt;
&lt;li&gt;I treated too much of the system as if it had to be tested manually&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix in each case was the same: make the boundary explicit, codify the workaround, and stop relying on implied behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  The debugging order that actually worked
&lt;/h2&gt;

&lt;p&gt;Once I noticed the pattern, I stopped starting with the prompt and started with the execution chain. This ordered checklist is the most transferable thing in this post, and it applies to any multi-agent system, not just Claude Code.&lt;/p&gt;

&lt;p&gt;In practice, I kept asking the same questions in the same order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Did the right worker actually run?&lt;/strong&gt; If the wrong agent was dispatched, or no agent existed for that role, nothing downstream mattered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did it have the tools I thought it had?&lt;/strong&gt; A skill that mentions &lt;code&gt;az&lt;/code&gt; is not the same thing as an agent that can execute &lt;code&gt;az&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was configuration complete?&lt;/strong&gt; If required fields were empty, the system needed to stop, not improvise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was I using a real invocation mechanism?&lt;/strong&gt; Slash-command looking syntax felt convenient, but convenience is not the same thing as runtime support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Was the tool available in this session or only in the parent?&lt;/strong&gt; MCP scope turned out to matter more than I expected.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only then: was the model reasoning actually wrong?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That order saved a lot of time. If step 1 is false, better prompting will not help. If step 3 is false, the best model in the world can only produce a sophisticated answer around missing inputs. If step 5 is false, you do not have an agent-quality problem. You have a system-boundary problem.&lt;/p&gt;

&lt;p&gt;That was one of the most useful mindset shifts of the whole project. Multi-agent debugging often looks like prompt debugging at first, but a lot of it is closer to distributed systems debugging: execution path, capability scope, state, and contracts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What changed in the repo because of these runs
&lt;/h2&gt;

&lt;p&gt;The first live incidents changed the codebase in very concrete ways.&lt;/p&gt;

&lt;p&gt;We added explicit evidence-agent definitions instead of assuming skills were executable on their own. We added &lt;code&gt;validate-expertise&lt;/code&gt; so incomplete setup blocked the run instead of quietly degrading into dry-run mode. We introduced an Onboarding Agent so filling the YAML files became a guided flow instead of a scavenger hunt. We rewrote skill invocations in agent instructions to explicit file-read-and-execute patterns. And we documented the CRM direct-call workaround in &lt;code&gt;decisions.md&lt;/code&gt; with a re-evaluation trigger instead of pretending the sub-agent path already worked.&lt;/p&gt;

&lt;p&gt;That sounds mundane, but that's the point. The system got better not because we discovered a magic prompt, but because we tightened the contracts around how it actually runs.&lt;/p&gt;

&lt;p&gt;That was the point where the support copilot stopped being a design exercise and started feeling like software.&lt;/p&gt;

&lt;p&gt;One thread from Part 1 that doesn't resurface here: the confidence formula (&lt;code&gt;FinalConf = sigmoid(Support - Conflict) × AgreementMultiplier&lt;/code&gt;) and the self-improvement loop through expertise YAML updates. The formula survived and is tested (Lesson 7). &lt;/p&gt;

&lt;p&gt;The next post is about productionization: Teams timeouts, async replies, attachments, blob storage, containerization, Azure deployment, and the IT-admin realities that only show up once you leave the notebook.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>debugging</category>
      <category>testing</category>
    </item>
    <item>
      <title>How I designed a multi-agent AI support copilot</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 27 Mar 2026 14:21:37 +0000</pubDate>
      <link>https://dev.to/eelcolos/how-i-designed-a-multi-agent-ai-support-copilot-4fpj</link>
      <guid>https://dev.to/eelcolos/how-i-designed-a-multi-agent-ai-support-copilot-4fpj</guid>
      <description>&lt;p&gt;Every support ticket starts the same way at our SaaS company: open the ticket, scan the description, then spend the next 15 minutes manually gathering context across five different systems. Check application telemetry for exceptions. Look up the customer in the CRM. Grep provisioning logs for failed sync events. Search work item systems and source control for related bugs. Cross-reference identity policy config if authentication is involved.&lt;/p&gt;

&lt;p&gt;That context is all out there. It's just scattered. By the time you've assembled it, you've already spent most of your time budget on information retrieval, not on reasoning.&lt;/p&gt;

&lt;p&gt;That's what I set out to fix. This is the first post in a series about the multi-agent AI support copilot.&lt;/p&gt;




&lt;h2&gt;
  
  
  The idea: a side-by-side AI copilot, not a replacement
&lt;/h2&gt;

&lt;p&gt;The typical AI support story starts with a ticket routing chatbot or an automatic responder. That's not this. We're not changing the helpdesk agent's job or asking the model to speak to the customer. Instead, we start a background process alongside the human support agent. The moment a ticket arrives, the copilot gathers context from the surrounding systems and checks whether that evidence corroborates, weakens, or contradicts what the customer is reporting.&lt;/p&gt;

&lt;p&gt;The first milestone is deliberately modest from the product side: show the ticket and the supporting evidence side by side so the support agent can reason faster with better context. Under the hood, the implementation already goes further and can synthesize ranked hypotheses with confidence scores, but I still want the first user-visible win to be evidence the human can inspect. For now, the human stays in charge.&lt;/p&gt;

&lt;p&gt;Everything that touches the customer still requires a human to approve it.&lt;/p&gt;

&lt;p&gt;The design had three hard requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parallel evidence gathering&lt;/strong&gt;: all domains queried at the same time, not sequentially&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured outputs&lt;/strong&gt;: every agent returns validated JSON, not prose that needs re-parsing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop for every action&lt;/strong&gt;: the copilot informs judgment, humans approve, deterministic code acts&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Where the design came from
&lt;/h2&gt;

&lt;p&gt;The initial architecture sketch came from a long ChatGPT conversation, the kind where you brain-dump a problem and the model helps you think through the components. That session produced the five-agent skeleton: identity resolution, observability telemetry, provisioning logs, work items, and a synthesis layer. It also produced the confidence arbitration formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FinalConf = sigmoid(Support - Conflict) × AgreementMultiplier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;Support&lt;/code&gt; and &lt;code&gt;Conflict&lt;/code&gt; are weighted sums of evidence claims, each claim weighted by the originating agent's reliability (&lt;code&gt;R_agent&lt;/code&gt;) and the claim's local confidence.&lt;/p&gt;

&lt;p&gt;The second source was internal: a reusable agentic scaffolding template we use for experiments. It had a graded architecture philosophy that mapped almost exactly to what we needed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Grade&lt;/th&gt;
&lt;th&gt;Template capability&lt;/th&gt;
&lt;th&gt;What we needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; session memory&lt;/td&gt;
&lt;td&gt;SupportAgent persona + IncidentContext schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Domain expertise YAML files&lt;/td&gt;
&lt;td&gt;Mental models per support domain (APM, CRM, SCIM, B2C, ALM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Skills (&lt;code&gt;SKILL.md&lt;/code&gt; with frontmatter)&lt;/td&gt;
&lt;td&gt;Evidence agent skill implementations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Closed-loop validation&lt;/td&gt;
&lt;td&gt;Re-evidence loop + Reviewer gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Orchestration (parallel worker dispatch)&lt;/td&gt;
&lt;td&gt;Management Agent with parallel evidence dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The template's &lt;code&gt;confidence_score&lt;/code&gt; field in expertise YAML files turned out to be exactly our &lt;code&gt;R_agent&lt;/code&gt; reliability weight. Agents that update their own expertise files after each incident self-improve over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two-layer repo structure
&lt;/h3&gt;

&lt;p&gt;The template introduced a two-layer structure we adopted verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;support-agent-research/
├── .agentic/              ← domain knowledge layer
│   ├── CLAUDE.md          - SupportAgent persona, session bootstrap
│   ├── memory/            - architecture decisions, ADR log
│   ├── plans/             - living docs for major system design changes
│   ├── expertise/         - per-domain YAML mental models
│   └── specs/             - per-incident IncidentContext files
│
└── .claude/               ← execution layer
    ├── agents/            - agent definitions (.md with frontmatter)
    ├── skills/            - evidence skill implementations (SKILL.md)
    └── settings.json      - hook wiring (Reviewer gate, Policy Gate)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that &lt;code&gt;.agentic/&lt;/code&gt; is the knowledge layer. It keeps the system's memory between incidents: persona, architecture notes, domain expertise, plans, and incident files. In this article, &lt;code&gt;plans/&lt;/code&gt; just shows that the system's design and build history live in the same place. &lt;code&gt;.claude/&lt;/code&gt; is the execution layer. It turns that knowledge into agents, skills, and hooks. Keeping them separate means you can update domain knowledge without touching execution logic, and vice versa.&lt;/p&gt;

&lt;h3&gt;
  
  
  How this matches broader agentic patterns
&lt;/h3&gt;

&lt;p&gt;I didn't invent this split in a vacuum. The Techorama 2025 sessions I captured in my notebook pointed at the same shape: a central orchestrator, specialist workers, shared context, and a clean split between knowledge and execution. I then turned that shape into a reusable agentic template, basically a starter kit that bootstraps those layers into a repo and keeps the supporting plans, memory, and delivery notes in one place.&lt;/p&gt;

&lt;p&gt;That also matches the plan modes now showing up in current CLIs: they separate thinking from doing. The template goes further by preserving state, specialist workers, and feedback loops, so the agent can plan, execute, and improve without starting from zero each time.&lt;/p&gt;

&lt;p&gt;These public repos corroborate the same building blocks. &lt;a href="https://github.com/microsoft/skills" rel="noopener noreferrer"&gt;microsoft/skills&lt;/a&gt; covers skills, custom agents, AGENTS.md templates, and MCP configs. &lt;a href="https://github.com/iannuttall/dotagents" rel="noopener noreferrer"&gt;dotagents&lt;/a&gt; and &lt;a href="https://github.com/iannuttall/source-agents" rel="noopener noreferrer"&gt;source-agents&lt;/a&gt; focus on keeping one canonical instruction set synchronized across tools. &lt;a href="https://github.com/BayramAnnakov/claude-reflect" rel="noopener noreferrer"&gt;claude-reflect&lt;/a&gt; turns corrections into durable memory and reusable skills. &lt;a href="https://github.com/agent-sh/agnix" rel="noopener noreferrer"&gt;agnix&lt;/a&gt; adds validation gates by linting agent configs before they break workflows. For the orchestrator-worker shape itself, I point to the architecture docs from Anthropic and OpenAI below rather than forcing a weak repo comparison.&lt;/p&gt;

&lt;p&gt;Anthropic draws a line between workflows and agents and recommends starting simple. The composable patterns they call out, like routing, parallelization, orchestrator-workers, and evaluator-optimizer loops, are the same kinds of building blocks I ended up using here. OpenAI's Agents SDK makes a similar point by keeping the primitive set small: instructions, tools, handoffs, guardrails, sessions, and tracing. It also separates orchestration done by the LLM from orchestration done in code. That distinction matters to me because I want the model to reason, but I want deterministic code to route work and enforce boundaries. See &lt;a href="https://www.anthropic.com/engineering/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic's "Building effective agents"&lt;/a&gt;, &lt;a href="https://openai.github.io/openai-agents-python/" rel="noopener noreferrer"&gt;OpenAI Agents SDK docs&lt;/a&gt;, and &lt;a href="https://openai.github.io/openai-agents-python/multi_agent/" rel="noopener noreferrer"&gt;OpenAI Agents orchestration docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; fits this pattern too. It's basically a repo-local README for agents, a human-readable place for durable instructions that complements the normal &lt;code&gt;README.md&lt;/code&gt;. That's exactly what &lt;code&gt;.agentic/CLAUDE.md&lt;/code&gt; is doing for me here. See &lt;a href="https://agents.md/" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;.agentic&lt;/code&gt; piece&lt;/th&gt;
&lt;th&gt;What it stores&lt;/th&gt;
&lt;th&gt;Broader pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.agentic/CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persona, operating rules, bootstrap guidance&lt;/td&gt;
&lt;td&gt;Repo-local agent instructions, AGENTS.md&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.agentic/memory/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Architecture decisions and shared context&lt;/td&gt;
&lt;td&gt;Durable instructions and session memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.agentic/plans/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Living record of the system's design and build history&lt;/td&gt;
&lt;td&gt;Bootstrap paths, planning, and rollout history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.agentic/expertise/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Domain-specific mental models&lt;/td&gt;
&lt;td&gt;Specialist workers and routing inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.agentic/specs/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Incident-scoped state and evidence&lt;/td&gt;
&lt;td&gt;Persistent session state, blackboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/agents/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worker definitions&lt;/td&gt;
&lt;td&gt;Handoffs, agent-as-tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Narrow deterministic actions&lt;/td&gt;
&lt;td&gt;Tools and guardrails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hook wiring and policy gates&lt;/td&gt;
&lt;td&gt;Deterministic routing and execution control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's why I ended up with a central Management Agent, specialized workers, a shared IncidentContext, and deterministic gates. The manager handles routing, the workers stay narrow, and the shared context gives every worker the same incident memory without letting them talk to each other directly. Then the reviewer and policy hooks decide what can actually happen. I want the model to gather evidence and propose answers, but I want code to decide when work is allowed to move forward. That same split shows up in &lt;a href="https://www.anthropic.com/engineering/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic's guidance&lt;/a&gt;, &lt;a href="https://openai.github.io/openai-agents-python/" rel="noopener noreferrer"&gt;OpenAI's Agents SDK docs&lt;/a&gt;, and &lt;a href="https://agents.md/" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------------+      +------------------------+
| .agentic               | ---&amp;gt; | .claude                |
| knowledge layer        |      | execution layer        |
|                        |      |                        |
| - CLAUDE.md            |      | - agents/              |
| - memory/              |      | - skills/              |
| - plans/               |      | - settings.json        |
| - expertise/           |      |                        |
| - specs/               |      |                        |
+------------------------+      +------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Core design decisions
&lt;/h3&gt;

&lt;p&gt;Before writing a line, we laid out the key architectural bets in &lt;code&gt;decisions.md&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrator-Worker over Decentralised&lt;/strong&gt;: agents never pass control to each other. This prevents circular reasoning and cascading hallucinations. The Orchestrator is the only router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IncidentContext as Blackboard&lt;/strong&gt;: the shared knowledge base all agents write to and the Synthesis Agent reads from. Enables cross-domain correlation without agent-to-agent communication. "Identity policy changed last week" + "provisioning returned null" + "telemetry shows NullReferenceException" all point to the same root cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI-first auth&lt;/strong&gt;: every skill uses the vendor's own CLI (&lt;code&gt;az&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;acli&lt;/code&gt;) for authentication. Skills are credential-free; the bootstrap section validates auth before any query runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No LLM both concludes and acts&lt;/strong&gt;: reasoning and execution are separate. The Policy Gate is deterministic code, not a model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Orchestrator-Worker at a glance
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;      Support ticket
            |
            v
     Management Agent &amp;lt; -----------
   /   |    |    |   \            |
  /    |    |    |    \           |
  v    v    v    v     v          |
 APM  CRM  SCIM  B2C  ALM         |
  \    |    |    |    /           |
   \   |    |    |   /            |
    v  v    v    v  v          max 2x
     IncidentContext              |
            |                     |
            v                     |
     Synthesis Agent              |
            |                     |
            v                     |
    Policy / Quality Gate - &amp;lt;0,65 -
            |
            v
      Reviewer Agent +
      Customer Draft
            |
            v
          Report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  IncidentContext at a glance
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IncidentContext
  - intake
  - resolved_identity
  - evidence
  - hypotheses
  - audit_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the design story. In part 2, I'll show what happened when the first live tickets hit the wiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic, "Building effective agents"&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.github.io/openai-agents-python/" rel="noopener noreferrer"&gt;OpenAI Agents SDK docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.github.io/openai-agents-python/multi_agent/" rel="noopener noreferrer"&gt;OpenAI Agents orchestration docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://agents.md/" rel="noopener noreferrer"&gt;AGENTS.md project&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Cross-boundary communication between desktop and web</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Mon, 23 Feb 2026 10:59:32 +0000</pubDate>
      <link>https://dev.to/eelcolos/cross-boundary-communication-between-desktop-and-web-575n</link>
      <guid>https://dev.to/eelcolos/cross-boundary-communication-between-desktop-and-web-575n</guid>
      <description>&lt;p&gt;We have a desktop product that customers actively use, and we want to migrate toward a SaaS offering. In practice, that means we need &lt;strong&gt;backwards compatibility&lt;/strong&gt; while we ship new features.&lt;/p&gt;

&lt;p&gt;During that transition you often end up in a "hybrid" state: a desktop shell still exists, but more and more UI and logic moves into web technology (for example hosted inside WebView2).&lt;/p&gt;

&lt;p&gt;That hybrid state introduces a core challenge: &lt;strong&gt;how do you preserve everyday interactions across boundaries?&lt;/strong&gt; Drag &amp;amp; drop, keyboard copy/paste, focus, selection, and other "it just works" behaviors tend to break the moment parts of the UI live in different browsing contexts (iframes/windows) or even in a host process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo repo (reference implementation):&lt;/strong&gt; &lt;a href="https://github.com/EelcoLos/iframe-dnd-demo" rel="noopener noreferrer"&gt;https://github.com/EelcoLos/iframe-dnd-demo&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://eelcolos.github.io/iframe-dnd-demo/" rel="noopener noreferrer"&gt;https://eelcolos.github.io/iframe-dnd-demo/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A pragmatic way to do incremental delivery is to introduce new modules behind &lt;strong&gt;explicit boundaries&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;embed legacy/new pieces via IFrame&lt;/li&gt;
&lt;li&gt;split experiences into separate windows when the host is desktop (or when multi-monitor workflows help)&lt;/li&gt;
&lt;li&gt;use Web Components to build reusable UI pieces without betting on one framework&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next question becomes: how do those pieces communicate so interactions (drag &amp;amp; drop, keyboard copy/paste) still work across boundaries. And how does it bridge to the desktop host?&lt;/p&gt;

&lt;p&gt;This post outlines a simple architecture: &lt;strong&gt;message passing all the way down&lt;/strong&gt;, but with clear layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DOM messaging for iframe ↔ parent (&lt;code&gt;window.postMessage&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Cross-window transport when needed (BroadcastChannel with fallbacks)&lt;/li&gt;
&lt;li&gt;WebView2 web messaging for web ↔ host (&lt;code&gt;window.chrome.webview.postMessage&lt;/code&gt;)
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------------ Desktop host (WebView2) -------------------------+
| WebMessageReceived  &amp;lt;---  chrome.webview.postMessage(...)                |
|        ^                                                                 |
|        |  CoreWebView2.PostWebMessageAsJson/String(...)                  |
|        |                                                                 |
|  +---------------- Parent web shell (coordinator) --------------------+  |
|  | iframe &amp;lt;-&amp;gt; parent: window.postMessage                              |  |
|  | cross-window (optional): BroadcastChannel                          |  |
|  |   fallback: postMessage relay via coordinator (Firefox ETP)        |  |
|  +--------------------------------------------------------------------+  |
+--------------------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;In the web layer, the parent shell acts as a coordinator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;routes messages between iframes&lt;/li&gt;
&lt;li&gt;handles cross-iframe pointer interactions (coordinate conversion, hit-testing)&lt;/li&gt;
&lt;li&gt;holds shared state (e.g., clipboard-like state for keyboard copy/paste)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the parent shell optionally bridges certain messages to the desktop host.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 1: iframe ↔ parent (DOM &lt;code&gt;postMessage&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;This is regular browser messaging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;set an explicit &lt;code&gt;targetOrigin&lt;/code&gt; (avoid &lt;code&gt;'*'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;validate the sender origin on receive&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the demo, this routing enables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pointer-based drag &amp;amp; drop across iframes&lt;/li&gt;
&lt;li&gt;keyboard copy/paste across iframes&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Layer 2: parent ↔ desktop host (WebView2 WebMessage)
&lt;/h2&gt;

&lt;p&gt;WebView2 provides a separate channel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JS → host: &lt;code&gt;window.chrome.webview.postMessage(...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;host → JS: &lt;code&gt;CoreWebView2.PostWebMessageAsJson/String(...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;JS receive: &lt;code&gt;window.chrome.webview.addEventListener('message', ...)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Microsoft’s docs emphasize treating web content as untrusted and validating origins / message payloads.&lt;/p&gt;
&lt;h2&gt;
  
  
  Message contracts: be explicit (the “action/type” field)
&lt;/h2&gt;

&lt;p&gt;The implementation choice I like most in this demo: every message carries a clear &lt;strong&gt;discriminator&lt;/strong&gt; so the receiver can route behavior.&lt;/p&gt;
&lt;h3&gt;
  
  
  Web ↔ web (iframes/windows): &lt;code&gt;type&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;In the browser-to-browser layer, the repo uses &lt;code&gt;type&lt;/code&gt; values like &lt;code&gt;dragStart&lt;/code&gt;, &lt;code&gt;parentDrop&lt;/code&gt;, &lt;code&gt;itemCopied&lt;/code&gt;, &lt;code&gt;requestPaste&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;Example shapes from the repo’s &lt;code&gt;API.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dragStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Item 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frame-a"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"parentDrop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"y"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;456&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dragData"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want versioning too, you can wrap that idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"itemCopied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frame-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"itemData"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Web ↔ host (WebView2): &lt;code&gt;action&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For the WebView2 host bridge, the demo uses an &lt;code&gt;action&lt;/code&gt; field for the same purpose (route on &lt;code&gt;action&lt;/code&gt; in &lt;code&gt;WebMessageReceived&lt;/code&gt;).&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"copy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unitPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"450"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Contract in action (from the demo repo)
&lt;/h3&gt;

&lt;p&gt;JS side (from &lt;code&gt;public/webcomponent-table-source-html5.html&lt;/code&gt;) posts JSON strings to the WebView2 host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If running in WebView2 (C# host), also send copy to native code&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webview&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;copy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selectedRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selectedRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selectedRow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&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;The same page also posts other actions like &lt;code&gt;dragstart&lt;/code&gt;, &lt;code&gt;dragend&lt;/code&gt;, and (on double-click) &lt;code&gt;drop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;C# side (from &lt;code&gt;WebView2App/HybridModeWindow.xaml.cs&lt;/code&gt;) receives and routes based on &lt;code&gt;action&lt;/code&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="n"&gt;WebViewSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CoreWebView2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WebMessageReceived&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;CoreWebView2_WebMessageReceived&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;void&lt;/span&gt; &lt;span class="nf"&gt;CoreWebView2_WebMessageReceived&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;?&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CoreWebView2WebMessageReceivedEventArgs&lt;/span&gt; &lt;span class="n"&gt;e&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;message&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="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetWebMessageAsString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ArgumentException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Message is not a string, try getting it as JSON&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WebMessageAsJson&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Message format examples:&lt;/span&gt;
    &lt;span class="c1"&gt;// {"action":"drop","description":"...","quantity":12,"unitPrice":450}&lt;/span&gt;
    &lt;span class="c1"&gt;// {"action":"copy","description":"...","quantity":"12","unitPrice":"450"}&lt;/span&gt;
    &lt;span class="k"&gt;if&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="nf"&gt;IsNullOrEmpty&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;return&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;json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&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;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootElement&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;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"action"&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;actionProp&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actionProp&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"drop"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// parse description/quantity/unitPrice and add to the target DataGridView&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&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;action&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"copy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// store the copied data so Ctrl+V in the target window can paste it&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;Why it matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can be backwards compatible across modules&lt;/li&gt;
&lt;li&gt;you can implement request/response via &lt;code&gt;correlationId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;you can validate shape (and reject unknown/untrusted messages)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing strategy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In-web behavior: Playwright is a good fit, treating iframes and windows as first-class.

&lt;ul&gt;
&lt;li&gt;Testing iframe drag and drop with Playwright&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Cross-window behavior: add tests that open child pages from a coordinator and assert keyboard interactions.&lt;/li&gt;

&lt;li&gt;Host bridge behavior: test separately at the desktop integration layer (WebMessageReceived handlers, navigation/origin checks).&lt;/li&gt;

&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security checklist (practical)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Validate origins for DOM postMessage: set a strict &lt;code&gt;targetOrigin&lt;/code&gt; on send, and on receive validate
&lt;code&gt;event.origin&lt;/code&gt; (allowlist) and optionally &lt;code&gt;event.source&lt;/code&gt; before trusting &lt;code&gt;event.data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In WebView2, always check the current document origin before trusting messages.&lt;/li&gt;
&lt;li&gt;Prefer JSON messages and validate schema.&lt;/li&gt;
&lt;li&gt;Disable features you don’t need (host objects, web messaging, scripts).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Web/native interop: &lt;a href="https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/communicate-btwn-web-native" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/communicate-btwn-web-native&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;WebView2 security: &lt;a href="https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/security" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/security&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Frames in WebView2: &lt;a href="https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/frames" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/frames&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo repo: &lt;a href="https://github.com/EelcoLos/iframe-dnd-demo" rel="noopener noreferrer"&gt;https://github.com/EelcoLos/iframe-dnd-demo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>javascript</category>
      <category>webview2</category>
      <category>webdev</category>
    </item>
    <item>
      <title>.NET File‑Based Apps for API Prototyping: What Bit Me on First Run</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Thu, 29 Jan 2026 12:03:21 +0000</pubDate>
      <link>https://dev.to/eelcolos/net-file-based-apps-for-api-prototyping-what-bit-me-on-first-run-na2</link>
      <guid>https://dev.to/eelcolos/net-file-based-apps-for-api-prototyping-what-bit-me-on-first-run-na2</guid>
      <description>&lt;p&gt;Using .NET file-based apps (via &lt;code&gt;dotnet run app.cs&lt;/code&gt;) enables rapid prototyping and simplified project structure by eliminating project scaffolding.&lt;br&gt;
This feature was announced for dotnet 10 just before .Net Conf 2025: &lt;a href="https://devblogs.microsoft.com/dotnet/announcing-dotnet-run-app/" rel="noopener noreferrer"&gt;https://devblogs.microsoft.com/dotnet/announcing-dotnet-run-app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s a neat way to try ideas quickly.&lt;br&gt;
I tried to find out if this would work with FastEndpoints to demo an API that way.&lt;/p&gt;
&lt;h2&gt;
  
  
  Example (FastEndpoints + file-based app)
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt; &lt;span class="n"&gt;Microsoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Web&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;package&lt;/span&gt; &lt;span class="n"&gt;FastEndpoints&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="m"&gt;7&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;FastEndpoints&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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&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;AddFastEndpoints&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;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseFastEndpoints&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&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;record&lt;/span&gt; &lt;span class="nc"&gt;MyRequest&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;FirstName&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;LastName&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;Age&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;record&lt;/span&gt; &lt;span class="nc"&gt;MyResponse&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;FullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsOver18&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;class&lt;/span&gt; &lt;span class="nc"&gt;MyEndpoint&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;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MyResponse&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;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/user/create"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;AllowAnonymous&lt;/span&gt;&lt;span class="p"&gt;();&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;override&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MyRequest&lt;/span&gt; &lt;span class="n"&gt;req&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;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OkAsync&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="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Age&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;18&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;cancellation&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;h2&gt;
  
  
  Gotcha: JSON/AOT defaults can break “works with web”
&lt;/h2&gt;

&lt;p&gt;When running with the web SDK in file-based apps, you can hit a runtime startup error like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System.NotSupportedException: JsonTypeInfo metadata for type
'System.Collections.Generic.IEnumerable`1[System.String]' was not provided by TypeInfoResolver of type
'[AppJsonSerializerContext]'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is effectively a &lt;code&gt;System.Text.Json&lt;/code&gt; source-generation/AOT mismatch: the app is configured to require generated metadata, but the required root types were not generated.&lt;br&gt;
This 'gotcha' is not pointed out in the dev blog, but one of the first bullet points at the learn article does hint at it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key benefits include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;   Reduced boilerplate for simple applications.&lt;/li&gt;
&lt;li&gt;   Self-contained source files with embedded configuration.&lt;/li&gt;
&lt;li&gt;   &lt;strong&gt;Native AOT publishing enabled by default.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;   Automatic packaging as .NET tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Why this happens (NativeAOT + JSON)
&lt;/h2&gt;

&lt;p&gt;With NativeAOT / trimming, reflection is restricted, so &lt;code&gt;System.Text.Json&lt;/code&gt; can't reliably do reflection-based (de)serialization.&lt;br&gt;
In practice, you need to provide compile-time metadata via &lt;strong&gt;source generation&lt;/strong&gt; (&lt;code&gt;JsonSerializerContext&lt;/code&gt; / &lt;code&gt;JsonSerializable&lt;/code&gt;), i.e. custom serialization setup for the types your endpoints bind/return.&lt;/p&gt;

&lt;p&gt;Because this is a lot of "serialization bookkeeping" for a single-file prototype, I currently prefer disabling NativeAOT for this scenario.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix (per gist): disable AOT
&lt;/h2&gt;

&lt;p&gt;To opt out of the default NativeAOT behavior (and avoid the JSON metadata requirement while prototyping), add this:&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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt; &lt;span class="n"&gt;PublishAot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs the app as regular JIT again, so &lt;code&gt;System.Text.Json&lt;/code&gt; can use reflection and the error goes away.&lt;/p&gt;

&lt;p&gt;You can find the entire gist at:&lt;br&gt;
&lt;a href="https://gist.github.com/EelcoLos/1c5f3c6be9ac765719ee880f3dcfec71" rel="noopener noreferrer"&gt;https://gist.github.com/EelcoLos/1c5f3c6be9ac765719ee880f3dcfec71&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://devblogs.microsoft.com/dotnet/announcing-dotnet-run-app/" rel="noopener noreferrer"&gt;https://devblogs.microsoft.com/dotnet/announcing-dotnet-run-app/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/core/sdk/file-based-apps" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/dotnet/core/sdk/file-based-apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/reflection-vs-source-generation" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/reflection-vs-source-generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/core/compatibility/serialization/8.0/publishtrimmed" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/dotnet/core/compatibility/serialization/8.0/publishtrimmed&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>fastendpoints</category>
      <category>aot</category>
    </item>
    <item>
      <title>Fantastic Knowledge &amp; How to Retain It</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Tue, 09 Dec 2025 15:29:44 +0000</pubDate>
      <link>https://dev.to/eelcolos/fantastic-knowledge-how-to-retain-it-3682</link>
      <guid>https://dev.to/eelcolos/fantastic-knowledge-how-to-retain-it-3682</guid>
      <description>&lt;p&gt;In the spirit of giving, consider this &lt;em&gt;a&lt;/em&gt; guide to retaining knowledge using a &lt;strong&gt;personal&lt;/strong&gt; knowledge base system. The goal is simple: turn 'aha' moments into captured insights you can reliably reuse. Then, turn those captured insights into reliable recall using everyday practices.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Thoughts to Notes
&lt;/h2&gt;

&lt;p&gt;When I'm talking about retaining knowledge, I myself think back to the days of note taking in school.&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%2Ff7fs2gs859ftdp4e1rlp.jpg" 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%2Ff7fs2gs859ftdp4e1rlp.jpg" alt="notes in the margin" width="800" height="602"&gt;&lt;/a&gt;&lt;br&gt;
Back then, before I had note-taking apps and wikis, I realized that remembering starts with externalizing. I used to fill the margins of books with notes, underlines, symbols, and even loose inlays. Small, personal anchors to help me retain what mattered. In this guide, we'll look at how to recreate that same kind of durable knowledge retention with modern tools and habits.&lt;/p&gt;

&lt;p&gt;From that, I learned a bit of the techniques that still work today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Techniques That Work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Capture 'aha' immediately: write a note, i.e. marginal notes or drop sticky inlays while interacting with information, especially the things that make you understand what is being written: the 'aha'.&lt;/li&gt;
&lt;li&gt;Title + one-sentence thesis: force clarity early so future-you retrieves faster.&lt;/li&gt;
&lt;li&gt;Context first: add why it matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example: What note did I create for Copilot Spaces
&lt;/h3&gt;

&lt;p&gt;So, to give an example of what I wrote when I first learned about Copilot Spaces (url: &lt;a href="https://github.com/copilot/spaces" rel="noopener noreferrer"&gt;https://github.com/copilot/spaces&lt;/a&gt;), I wrote the following:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Within GitHub, &lt;strong&gt;Copilot Spaces&lt;/strong&gt; can serve as focused, persistent workspaces for specific knowledge domains (e.g. "knowledge retainment", "copilot agent"). Each Space aggregates code examples, ADRs, runbooks, exploratory prototypes, and curated prompts, reducing siloed tribal knowledge and enabling asynchronous onboarding.&lt;br&gt;
it does help for [[zettelkasten]] type of knowledge, as it scopes to the topic itself&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It shows the 'aha', for it being a shareable topic scoped option for GitHub regarding knowledge retainment. Especially use of aggregate code examples, ADRs, etc.&lt;br&gt;
the 'Why it matters' is addressed as well: it helps the Zettelkasten type of knowledge, scoping it to topic.&lt;br&gt;
As title, I defined this as 'copilot-spaces-can-be-topic-specific-knowledge-base'.&lt;/p&gt;

&lt;p&gt;Now, these techniques make you have a bunch of notes, but these by themselves are a ball-of-mud. So, after this, we want to think of mapping these thoughts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Mind Mapping Still Matters
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Mind mapping is a brainstorming technique that organizes information hierarchically around a central topic&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The brainstorming technique on the topic can be disjointed from the information retrieval, like a conference. Though you can brainstorm on the topic while being present on the session too. &lt;br&gt;
At that moment, you can bind the information of the retrieval session to an idea. &lt;br&gt;
To have this information structured in a general way of thinking, we can use a system called 'Zettelkasten'.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zettelkasten: Core Workflow Anatomy
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The Zettelkasten method (German for 'slip box' or 'note box') is a system for personal knowledge management and note-taking, developed by the German sociologist Niklas Luhmann. Its purpose is to capture and connect ideas in a dynamic network that fosters creative thinking and the generation of new insights, rather than simply collecting information. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The 'zettel' (slip) is a note in a 'kast' (box) to organize (parts of) a topic. As you build more and more notes an organized box can look 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%2Frkz2p2d3vv4atdjhvz07.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%2Frkz2p2d3vv4atdjhvz07.png" alt="analog zettelkasten" width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Zettelkasten note &lt;em&gt;can&lt;/em&gt; have a lot of information on it. what it primarily should do is to help you structure the earlier created note in a way that it can be found and that it finds its way into other information you found before.&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%2Far16luszmy7bs7swo4mn.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%2Far16luszmy7bs7swo4mn.png" alt="note-overview" width="800" height="415"&gt;&lt;/a&gt;&lt;br&gt;
The body is still here and still the most important. It's just being added with metadata in the header, such as the unique identifier title and tags regarding which topics it's close to.&lt;br&gt;
A footer can be used; it's main regarding source of knowledge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a web of information using links
&lt;/h3&gt;

&lt;p&gt;The earlier created title in the example shows usage here in the 'linkage'. As shown in the example above, you can use double brackets to reference a link to another note. These connections are in a way your 'oh, this thing refers to another'. For me, the earlier usage of 'copilot-spaces-can-be-topic-specific-knowledge-base', makes the searching of such notes easier, as it would start or complete a (phrase of a) sentence.&lt;br&gt;
When you get to connect a decent chunk of these cards, you can probably also say something about the higher level topic. To me, in this regard of the title just now, it could be regarding this blog: writing about how Zettelkasten works for me. It wouldn't be an entire blog, but just some keywords for you to write what things are about and how that connects on a grand stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  When would you use it then?
&lt;/h3&gt;

&lt;p&gt;I've been asked as well: well, nice and all, but storing a lot and not using it, is just hoarding.&lt;br&gt;
All my blogs so far are mainly made of those 'aha' moments in one way or another. Furthermore, I can think of your observability log queries that you might need from time to time to be those 'aha' or 'don't google this over and over again' sections. I've lately been saving Application Insights  KQL queries. So, applying it to recall occasionally used information is a good use-case. &lt;/p&gt;

&lt;p&gt;Should you carry a notebook with you at all times learning about this? Maybe so, maybe not. It doesn't have to be your main retainer of notes though. We have tools to help store them digitally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing Notes Digitally (Obsidian, Notion)
&lt;/h2&gt;

&lt;p&gt;For Zettelkasten-style notes, Obsidian and Notion both work well: Obsidian excels with local Markdown and fast link creation; Notion adds flexible databases and views. My setup leans toward  Markdown for speed, with occasional Obsidian views for structure notes that index neighborhoods of related ideas. Whatever you choose, keep notes atomic, use clear titles, and maintain lightweight structure notes rather than sprawling folders.&lt;/p&gt;

&lt;p&gt;My experience transferring knowledge from some keywords or one sentence in a knowledge base system, and have it already have links, etc. is really hard. Too hard to maintain yourself, I would argue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using AI to help your knowledgebase
&lt;/h2&gt;

&lt;p&gt;Then I noticed a YouTube video by NetworkChuck titled 'You've Been Using AI the Hard Way'. In it, he describes the use of AI as an accelerator. The CLI tools like Gemini CLI, Copilot and Claude might focus on code by default, but it is never stated that they should &lt;strong&gt;solely&lt;/strong&gt; be used for it.&lt;br&gt;
In fact, if you pass a small instruction file to those CLI tools, you can explain that you'd like the output in a Zettelkasten form. This way, you can leverage the 'aha' moment even more: state the topic and 'aha' to the LLM and it'll create the note for you.&lt;/p&gt;

&lt;p&gt;AI can also help as a research tool. For this, you could also check out &lt;strong&gt;NotebookLM&lt;/strong&gt; from Google. Technically, you should be able to have the same result via CLI. However, I tried to get a training regarding musical world building with a dozen YouTube, PDF and website sources. Here, NotebookLM &lt;em&gt;does&lt;/em&gt; provide value over cli in my humble opinion. It could be because it will use the entire transcripts of YouTube videos, whereas the CLI will only use your markdown notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Niklas Luhmann - Slip-box Method (original practice descriptions and archival analyses). also   &lt;a href="https://zettelkasten.de/introduction/" rel="noopener noreferrer"&gt;https://zettelkasten.de/introduction/&lt;/a&gt; has a lot of information on this topic.&lt;/li&gt;
&lt;li&gt;NetworkChuck - "You've Been Using AI the Hard Way (Use This Instead)" (video): &lt;a href="https://www.youtube.com/watch?v=MsQACpcuTkU" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=MsQACpcuTkU&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Copilot Spaces - Topic-scoped knowledge workspaces: &lt;a href="https://docs.github.com/en/copilot/concepts/context/spaces" rel="noopener noreferrer"&gt;https://docs.github.com/en/copilot/concepts/context/spaces&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NotebookLM - Using external sources (e.g., videos) to augment a personal knowledge base: &lt;a href="https://notebooklm.google/" rel="noopener noreferrer"&gt;https://notebooklm.google/&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Personal Experience Highlights
&lt;/h2&gt;

&lt;p&gt;So, all in all, my highlights on retaining knowledge are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic-scoped workspaces (e.g., Copilot Spaces) function as focused knowledge bases; scoping artifacts and prompts per topic improves retrieval and supports a Zettelkasten-like flow. In Obsidian vaults these can be folders.&lt;/li&gt;
&lt;li&gt;CLI-driven AI (e.g. Gemini CLI) is faster than browser UIs and works directly with local Markdown notes, keeping ideation and distillation tightly integrated.&lt;/li&gt;
&lt;li&gt;Experimenting with NotebookLM and AI CLI to incorporate external sources (like videos) into the graph, followed by manual distillation into atomic notes and vetted links.&lt;/li&gt;
&lt;li&gt;Treat the system as an active thinking partner: keep notes atomic, rewrite often, and periodically refresh structure notes that organize neighborhoods.&lt;/li&gt;
&lt;li&gt;Tooling matters: VS Code with lightweight mind-mapping add-ons accelerates the map-to-note funnel without turning maps into storage.&lt;/li&gt;
&lt;li&gt;Converting quotes into single-idea notes and linking back to an overview improves reuse and prevents annotation bloat.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A healthy Zettelkasten is less a knowledge base 'warehouse' and more a conversational partner. Mind maps and AI copilots amplify its adaptability when used as catalysts, not crutches. The goal is compound insight: small, precise notes that keep paying dividends across projects and time.&lt;/p&gt;

</description>
      <category>knowledgemanagement</category>
      <category>ai</category>
      <category>zettelkasten</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Lessons learned implementing SCIM with Microsoft Entra and the SCIM Validator</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 28 Nov 2025 15:00:54 +0000</pubDate>
      <link>https://dev.to/eelcolos/lessons-learned-implementing-scim-with-microsoft-entra-and-the-scim-validator-20gl</link>
      <guid>https://dev.to/eelcolos/lessons-learned-implementing-scim-with-microsoft-entra-and-the-scim-validator-20gl</guid>
      <description>&lt;p&gt;I had to redo the entire SCIM validator journey after we've migrated to a new company. This article shares the practical lessons from that rework: tightening concurrency, adding hybrid caching, clarifying when /Schemas actually matters and structuring validation runs so progress is repeatable instead of guesswork. SCIM still promises automated provisioning from an IdP, but the path to a production-grade, Entra-compatible implementation is about disciplined iteration, not just “passing the tests.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SCIM
&lt;/h2&gt;

&lt;p&gt;Once integrated, lifecycle events (create, update, deactivate) flow from your IdP without manual admin work, improving security posture and reducing bespoke connector logic. Interoperability hinges on spec fidelity: consistent status codes, predictable resource representation, correct filtering, and pagination behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Train of thought
&lt;/h2&gt;

&lt;p&gt;The Microsoft SCIM Validator applies stricter rules for connectors intended for publication in the Microsoft Entra app store. If targeting store publication, aim for full compliance across correctness, concurrency, and performance; if not, be mostly compliant with RFC 7643/7644 and Entra guidance while prioritizing reliability and pragmatic trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation strategy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Start with the synchronous (“preview”) validator suite to establish deterministic behavior. This path exposed spec gaps cleanly (status codes, PATCH semantics, schema responses) without concurrency noise.&lt;/li&gt;
&lt;li&gt;Tackle the parallel/asynchronous suite only after idempotency and locking are solid. Rapid sequences (like PATCH immediately after CREATE) surfaced race conditions when write visibility lagged behind reads.&lt;/li&gt;
&lt;li&gt;Use slight pacing as a diagnostic tool (about 300ms between requests) to reduce false negatives from artificial races; remove pacing once your server proves truly concurrent-safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Caching
&lt;/h2&gt;

&lt;p&gt;Essential for Entra-compatible performance. The validator repeatedly fetches stable resources (e.g., &lt;code&gt;/Schemas&lt;/code&gt;, frequent user/group reads), so hitting your datastore for every request becomes a bottleneck and tempts reimplementation of local validator logic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Internal/dev: a simple in-memory cache (e.g., &lt;code&gt;IMemoryCache&lt;/code&gt;) suffices for single-instance development.&lt;/li&gt;
&lt;li&gt;Production/multi-instance: a hybrid model with in-memory plus a distributed cache (e.g., Redis) improves throughput and keeps repeated fetches fast across instances.&lt;/li&gt;
&lt;li&gt;Cache stable data (schemas, group memberships that change infrequently) aggressively; respect cache invalidation for writes and reflect updated &lt;code&gt;etags&lt;/code&gt;/versioning correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Concurrency and idempotency
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Make PUT/PATCH idempotent — retries from the validator should not corrupt state or produce inconsistent responses.&lt;/li&gt;
&lt;li&gt;Adopt optimistic concurrency (versioning or &lt;code&gt;etags&lt;/code&gt;) and enforce If-Match semantics where applicable, so conflicting updates are clear and recoverable.&lt;/li&gt;
&lt;li&gt;Normalize case for attributes (userName, emails) to avoid equality-test failures, and ensure multi-valued attributes return deterministic ordering or include explicit primary markers.&lt;/li&gt;
&lt;li&gt;Throttle intentionally with &lt;code&gt;429 Too Many Requests&lt;/code&gt; and include Retry-After; avoid generic 500s under load which obscure recoverable capacity issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Spec coverage priorities
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Implement &lt;code&gt;/Schemas&lt;/code&gt; early if targeting Microsoft Entra store publication (the validator uses it for capability discovery). For non-store implementations, &lt;code&gt;/Schemas&lt;/code&gt; is not required; focus on RFC 7643/7644 compliance and core provisioning endpoints.&lt;/li&gt;
&lt;li&gt;Return precise status codes: 200/201/204 for success paths, 400 for bad requests, 404 for unknown resources, 409 for conflicts, 412 for precondition failures, and 429 for throttling.&lt;/li&gt;
&lt;li&gt;Gradually flesh out filtering and pagination. Support the basics first (_startIndex, count, filter eq/and), then expand to more complex filters once stability is proven.&lt;/li&gt;
&lt;li&gt;Ensure consistent resource representations and stable IDs; avoid transient fields that vary run-to-run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Local exposure &amp;amp; tunneling
&lt;/h2&gt;

&lt;p&gt;High-fidelity SCIM validation requires the external validator to reach your local endpoint reliably. Lightweight tunnels (e.g., &lt;code&gt;ngrok&lt;/code&gt;) are fast to start but their free/low tiers impose connection/session limits that parallel SCIM runs can exhaust. Azure Dev Tunnels (&lt;code&gt;devtunnel&lt;/code&gt;) offer longer-lived, higher-volume sessions with configurable access, reducing flakiness during heavy validator cycles.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start local app on http://localhost:5010 (HTTP)&lt;/span&gt;
ngrok http 5010

&lt;span class="c"&gt;# Azure Dev Tunnel for HTTPS endpoint on port 5011 (longer-lived, higher volume)&lt;/span&gt;
devtunnel host &lt;span class="nt"&gt;-p&lt;/span&gt; 5011 &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use a consistent port strategy (5010 HTTP dev, 5011 HTTPS) to simplify validator configuration.&lt;/li&gt;
&lt;li&gt;Regenerate tunnels per session; do not reuse stale URLs in cached validator runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tunneling checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose &lt;code&gt;ngrok&lt;/code&gt; for quick, short sessions; switch to &lt;code&gt;devtunnel&lt;/code&gt; for sustained or parallel test runs.&lt;/li&gt;
&lt;li&gt;Prefer HTTPS tunnel endpoints to match production security and avoid mixed content.&lt;/li&gt;
&lt;li&gt;Capture and inject the tunnel URL into validator configuration (SCIM base), then tear down afterward.&lt;/li&gt;
&lt;li&gt;Monitor for throttling/timeouts; change provider if limits introduce test noise.&lt;/li&gt;
&lt;li&gt;Avoid exposing non-SCIM debug endpoints; scope the tunnel to required routes only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Operational tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Validate your SCIM base URL and TLS setup; the Microsoft SCIM Validator needs a reachable, secure endpoint with correct schema and auth.&lt;/li&gt;
&lt;li&gt;Log at correlation/request IDs to tie parallel validation threads back to server decisions, especially when diagnosing races or caching effects.&lt;/li&gt;
&lt;li&gt;Document supported attributes and mapping expectations for IdP admins; clarity reduces misconfiguration and brittle assumptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Recommended path to success
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Decide early whether your connector targets Microsoft Entra store publication; choose the validator implementation accordingly (store-level: stricter; pass all, general: mostly compliant; pass most).&lt;/li&gt;
&lt;li&gt;Pass the synchronous validator suite to establish a clean baseline and identify spec gaps without concurrency noise.&lt;/li&gt;
&lt;li&gt;Add hybrid caching and optimistic concurrency to harden performance and correctness (idempotent PUT/PATCH, etags/If-Match, 429 with Retry-After).&lt;/li&gt;
&lt;li&gt;Run the parallel suite with slight pacing (~300ms) to isolate true concurrency defects, then remove pacing once stable.&lt;/li&gt;
&lt;li&gt;Re-run under realistic concurrent load and verify determinism in responses, versions, and resource representations.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;SCIM success is less about “just pass the tests” and more about disciplined concurrency, idempotency, and caching that make those tests repeatably pass at scale. Treat the synchronous suite as the baseline for correctness, and the parallel suite as the proving ground for production readiness.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SCIM Validator: &lt;a href="https://scimvalidator.microsoft.com/" rel="noopener noreferrer"&gt;https://scimvalidator.microsoft.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Entra SCIM guide: &lt;a href="https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SCIM RFC 7643 (Schema): &lt;a href="https://www.rfc-editor.org/rfc/rfc7643" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc7643&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SCIM RFC 7644 (Protocol): &lt;a href="https://www.rfc-editor.org/rfc/rfc7644" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc7644&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>scim</category>
      <category>identity</category>
      <category>azure</category>
      <category>provisioning</category>
    </item>
    <item>
      <title>Where coding agents excel (and where they don't)</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Thu, 06 Nov 2025 07:04:52 +0000</pubDate>
      <link>https://dev.to/eelcolos/where-coding-agents-excel-and-where-they-dont-kba</link>
      <guid>https://dev.to/eelcolos/where-coding-agents-excel-and-where-they-dont-kba</guid>
      <description>&lt;p&gt;This is a first-person, hands-on writeup of how I experienced using Copilot coding agents today. I include examples, a gotcha I hit while using it, and a short checklist so you can try it in your repos.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I started using Copilot coding agents
&lt;/h2&gt;

&lt;p&gt;I started experimenting with Copilot coding agents because I wanted to scaffold a lot of things which IDE-based agents do not, and to see what an assistant could do for me (refactoring scaffolding, creating test harnesses, running quick migrations) and actually &lt;em&gt;execute&lt;/em&gt; code in a prepared environment instead of just suggesting edits. Over the last few months I tested agents on various projects and refined a small set of rules that helped me experience the current possibilities of delegating tasks to an agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Beyond IDE Agents: What Copilot Coding Agents Can Do
&lt;/h2&gt;

&lt;p&gt;Unlike traditional IDE-based agents, Copilot coding agents can perform a broader range of actions, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Branch Creation:&lt;/strong&gt; Automatically create new branches for development tasks.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Environment Setup and WIP PRs:&lt;/strong&gt; Set up development environments and initiate Work-In-Progress (WIP) Pull Requests on GitHub. &lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Task/Issue Deduction:&lt;/strong&gt; Understand and deduce tasks from issues or descriptions. Those tasks will be put in the WIP PR description.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Building and Running Tasks:&lt;/strong&gt; Execute build processes and run various development tasks defined in earlier steps.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;WIP PR to Draft PR:&lt;/strong&gt; Transition a WIP Pull Request to a draft Pull Request with a pre-filled body.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where coding agents excel (and where they don't)
&lt;/h2&gt;

&lt;p&gt;Based on my experience, here's where coding agents currently shine and where they struggle:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ Good at:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Little tasks:&lt;/strong&gt; Small, well-defined tasks are handled well.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Dependency updates:&lt;/strong&gt; Updating dependencies is a task that agents can perform reliably.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Okay at:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Tasks with a lot of moving parts:&lt;/strong&gt; For example, major refactors can be challenging for agents to handle on their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;❌ Terrible at:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Major UI things:&lt;/strong&gt; User interface work is not a strong suit for coding agents at the moment.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How agents run in practice: the &lt;code&gt;copilot-setup-steps.yml&lt;/code&gt; convention
&lt;/h2&gt;

&lt;p&gt;The typical flow I use now is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;.github/workflows/copilot-setup-steps.yml&lt;/code&gt; on the repository's default branch.&lt;/li&gt;
&lt;li&gt;In that workflow, prepare everything the agent needs: runtimes, credentials (via environment secrets), databases, caches.&lt;/li&gt;
&lt;li&gt;Let the agent run against that prepared environment:  the agent then performs code changes, runs tests, and can open PRs as usual.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Treat &lt;code&gt;copilot-setup-steps.yml&lt;/code&gt; as a &lt;strong&gt;minimal pre-flight&lt;/strong&gt; script: it should be fast, idempotent, and only prepare what the agent actually needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimal example I use as a starting point
&lt;/h3&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;GitHub Copilot Setup&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;workflow_dispatch&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;copilot-setup-steps&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 Environment for GitHub Copilot&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&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;environment&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;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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup dotnet&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@v5&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;9.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;checkout&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@v5&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;Configure NuGet sources&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;if [[ -n "${{ secrets.PACKAGES_PAT }}" ]]; then&lt;/span&gt;
            &lt;span class="s"&gt;dotnet nuget update source "github" --username this-is-irrelevant --password ${{ secrets.PACKAGES_PAT }} --store-password-in-clear-text&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;echo "Could not find Packages PAT"&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&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;Azure login&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;azure/login@v2&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;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CLIENT_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;tenant-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_TENANT_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;subscription-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_SUBSCRIPTION_ID }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Notes from my experience:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the job name should be &lt;code&gt;copilot-setup-steps&lt;/code&gt; and file &lt;code&gt;copilot-setup-steps.yml&lt;/code&gt; &lt;strong&gt;exactly&lt;/strong&gt;. others are ignored.&lt;/li&gt;
&lt;li&gt;Keep this workflow &lt;strong&gt;quick&lt;/strong&gt; — long boot times mean more waiting (and, on paid plans, more compute cost).&lt;/li&gt;
&lt;li&gt;remember to have permissions regarding &lt;code&gt;checkout&lt;/code&gt; and your login. Respectively &lt;code&gt;contents: read&lt;/code&gt; and &lt;code&gt;id-token: write&lt;/code&gt;. &lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Secrets, environments, and the things that bit me
&lt;/h2&gt;

&lt;p&gt;I use a repository Environment named &lt;code&gt;copilot&lt;/code&gt; and put environment variables and secrets there instead of raw repository secrets. In practice put credentials the agent legitimately needs into the &lt;code&gt;copilot&lt;/code&gt; environment in the repo settings. Limit who can modify that environment. You might need to put organization credentials here as well. This mindset seems to be changing from time to time. Earlier, this wasn't necessary, now it is.&lt;/p&gt;

&lt;p&gt;A gotcha I ran into and how I handled them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites not always picked up&lt;/strong&gt;: Even when I put prerequisites steps in a GitHub issue, the agent wouldn't always pick them up. I found I needed to refer to the prerequisites in between every step. Luckily, with the new steering capabilities, I could guide the agent mid-session to ensure it followed the necessary steps.&lt;/p&gt;




&lt;h2&gt;
  
  
  A practical checklist (so you can try this quickly)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;.github/workflows/copilot-setup-steps.yml&lt;/code&gt; to your default branch.&lt;/li&gt;
&lt;li&gt;Add a repo environment named &lt;code&gt;copilot&lt;/code&gt; and add environment variables / environment secrets used by the setup workflow.&lt;/li&gt;
&lt;li&gt;Keep the setup job fast: install only what is necessary and cache dependencies.&lt;/li&gt;
&lt;li&gt;Consider self-hosted runners if you need internal network access or want to keep secrets entirely on-prem.&lt;/li&gt;
&lt;li&gt;Add a short &lt;code&gt;AGENTS.md&lt;/code&gt; describing rules (what an agent may change, code style, testing expectations).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Defining Agent Instructions: The &lt;code&gt;AGENTS.md&lt;/code&gt; Approach
&lt;/h2&gt;

&lt;p&gt;To ensure your Copilot coding agent operates effectively and adheres to project standards, it's crucial to provide clear and explicit instructions. A common practice is to create an &lt;code&gt;AGENTS.md&lt;/code&gt; file (or &lt;code&gt;copilot-instructions.md&lt;/code&gt; as I initially used) in your repository to house these guidelines. These instructions act as guardrails, directing the agent's behavior and ensuring consistency.&lt;/p&gt;

&lt;p&gt;Here are some examples of instructions I've used to guide my Copilot agent (rename 'Test' and 'Production' to your own purposes):&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="p"&gt;*&lt;/span&gt;   &lt;span class="gs"&gt;**Azure Best Practices:**&lt;/span&gt; When working with Azure, always invoke the &lt;span class="sb"&gt;`azure_development-get_best_practices`&lt;/span&gt; tool if available to ensure adherence to best practices.
&lt;span class="p"&gt;*&lt;/span&gt;   &lt;span class="gs"&gt;**E2E Test Rules:**&lt;/span&gt; Ensure End-to-End (E2E) tests do not contain &lt;span class="sb"&gt;`.only`&lt;/span&gt; in &lt;span class="sb"&gt;`.describe`&lt;/span&gt; or &lt;span class="sb"&gt;`.it`&lt;/span&gt; blocks. Remove any instances found.
&lt;span class="p"&gt;*&lt;/span&gt;   &lt;span class="gs"&gt;**Coding Standards:**&lt;/span&gt; Apply specific coding standards for languages like C#, Bicep, Dockerfile, and TypeScript by following guidelines in their respective instruction files (e.g., &lt;span class="sb"&gt;`./.github/instructions/csharp.instructions.md`&lt;/span&gt;).
&lt;span class="p"&gt;*&lt;/span&gt;   &lt;span class="gs"&gt;**Azure Environment Rules:**&lt;/span&gt; Adhere to strict rules for Azure deployments:
&lt;span class="p"&gt;    *&lt;/span&gt;   Use 'Test' for Test deployments and 'Production' for Production deployments.
&lt;span class="p"&gt;    *&lt;/span&gt;   Never deploy to test and production simultaneously.
&lt;span class="p"&gt;    *&lt;/span&gt;   Production deployments require PIM (Privileged Identity Management) role activation.
&lt;span class="p"&gt;    *&lt;/span&gt;   Prefer updating Bicep templates over direct Azure CLI modifications for infrastructure changes.
&lt;span class="p"&gt;*&lt;/span&gt;   &lt;span class="gs"&gt;**ADR Adherence:**&lt;/span&gt; Before implementing changes, review accepted Architectural Decision Records (ADRs) in the &lt;span class="sb"&gt;`docs/design-decisions`&lt;/span&gt; directory, ensuring their status is "Accepted" and considering their consequences.
&lt;span class="p"&gt;*&lt;/span&gt;   &lt;span class="gs"&gt;**PR Creation:**&lt;/span&gt; When creating a Pull Request, include a reference to an Azure Boards work item using the format &lt;span class="sb"&gt;`[AB#12345]`&lt;/span&gt; above any headings in the PR description.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Pitfalls &amp;amp; advice from real runs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explore the capabilities of coding agents, with boundaries&lt;/strong&gt;: Know what it's good at (the small things first). Then, as you start to build your repo with instructions, gradually increase the hand-off to coding agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write clear tasks&lt;/strong&gt;: the agent will be much more useful if you give it a ticket with clear acceptance criteria and a test it must pass. I started using a PR template specifically for agent-generated PRs so reviewers know what to focus on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review everything&lt;/strong&gt;: agents will make suggestions that look plausible but can introduce subtle mistakes. Always review and run the test suite locally.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;I like how the combination of mission control (Agent HQ) + explicit pre-flight workflows made agents &lt;em&gt;useful&lt;/em&gt; rather than noisy. The plan mode and steering affordances gave me the confidence to run medium-length tasks without babysitting everything.&lt;/p&gt;

&lt;p&gt;At the same time, good repository hygiene is now essential: lock permissions on the &lt;code&gt;copilot&lt;/code&gt; environment, and maintain a short &lt;code&gt;AGENTS.md&lt;/code&gt; documenting what agents should and should not do. If you need absolute control over secrets or internal network access, &lt;/p&gt;

&lt;p&gt;Please try it 3 times before judging. Share your thoughts in the comments below.&lt;/p&gt;

</description>
      <category>github</category>
      <category>codingagent</category>
      <category>agentic</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why Your ASP.NET Core LogLevel 'Warning' Still Sends Information Logs to Application Insights</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Mon, 29 Sep 2025 05:32:39 +0000</pubDate>
      <link>https://dev.to/eelcolos/why-your-aspnet-core-loglevel-warning-still-sends-information-logs-to-application-insights-e94</link>
      <guid>https://dev.to/eelcolos/why-your-aspnet-core-loglevel-warning-still-sends-information-logs-to-application-insights-e94</guid>
      <description>&lt;p&gt;I first noticed this pattern while helping a teammate trim noisy telemetry costs. We had set the global logging level in an ASP.NET Core app to Warning. We redeployed. Yet the Azure Portal continued to show a steady stream of Information traces arriving from the same service. It felt like the platform was ignoring us. It wasn’t. We were ignoring a subtle layering rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The puzzle
&lt;/h2&gt;

&lt;p&gt;Configuration (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Logging"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"LogLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ApplicationInsights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"LogLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone expected Warning to be the minimum loglevel. Application Insights kept getting &lt;code&gt;Information&lt;/code&gt; entries. The instinct is to blame the SDK. The real cause is that provider specific configuration lowers the threshold just for that provider. The Application Insights logger never re‑applies a minimum. It faithfully forwards what the &lt;code&gt;Microsoft.Extensions.Logging&lt;/code&gt; infrastructure lets through. That infrastructure had already been told: for the provider whose alias is ApplicationInsights(Abbreviated: AI), allow &lt;code&gt;Information&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the layers actually line up
&lt;/h2&gt;

&lt;p&gt;Think of the journey of a log:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your code calls &lt;code&gt;logger.LogInformation("User logged in")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The generic logging infrastructure consults all configured filters. A global default says Warning. A provider override for ApplicationInsights says Information. Because the target is that provider, Information is allowed.&lt;/li&gt;
&lt;li&gt;The Application Insights logger receives the entry. It calls its own &lt;code&gt;IsEnabled&lt;/code&gt;. That only checks two things: the level is not None and telemetry has not been globally disabled.&lt;/li&gt;
&lt;li&gt;The logger maps &lt;code&gt;LogLevel.Information&lt;/code&gt; to &lt;code&gt;SeverityLevel.Information&lt;/code&gt; and sends a &lt;code&gt;TraceTelemetry&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Optional sampling or processors may still drop it downstream, but cost has already been incurred in your process and often in ingestion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No secret minimum. No hidden widening. Just configuration precedence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The confirmation in code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/microsoft/ApplicationInsights-dotnet/blob/98bc6c540e2dcccc78e4b356cd70e03d146a01ad/LOGGING/src/ILogger/ApplicationInsightsLogger.cs#L59-L69" rel="noopener noreferrer"&gt;&lt;code&gt;IsEnabled&lt;/code&gt; inside the AI logger&lt;/a&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;IsEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt; &lt;span class="n"&gt;logLevel&lt;/span&gt;&lt;span class="p"&gt;)&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;logLevel&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;telemetryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsEnabled&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;a href="https://github.com/microsoft/ApplicationInsights-dotnet/blob/98bc6c540e2dcccc78e4b356cd70e03d146a01ad/LOGGING/src/ILogger/ApplicationInsightsLogger.cs#L125-L147" rel="noopener noreferrer"&gt;Level mapping (translation only)&lt;/a&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;SeverityLevel&lt;/span&gt; &lt;span class="nf"&gt;GetSeverityLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt; &lt;span class="n"&gt;logLevel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logLevel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Critical&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;SeverityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Critical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LogLevel&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SeverityLevel&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="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&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;SeverityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&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;SeverityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trace&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SeverityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verbose&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;&lt;a href="https://github.com/microsoft/ApplicationInsights-dotnet/blob/98bc6c540e2dcccc78e4b356cd70e03d146a01ad/BASE/src/Microsoft.ApplicationInsights/TelemetryClient.cs#L86-L100" rel="noopener noreferrer"&gt;&lt;code&gt;TelemetryClient.IsEnabled()&lt;/code&gt;&lt;/a&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="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;IsEnabled&lt;/span&gt;&lt;span class="p"&gt;()&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisableTelemetry&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;So if telemetry is not disabled, everything that passed filtering is shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where teams stumble
&lt;/h2&gt;

&lt;p&gt;A provider override left behind by a template or copy paste. A category override intended for console logs but applied broadly. Or sampling giving the &lt;em&gt;illusion&lt;/em&gt; that filtering is working because only some Information entries survive. All of these hide the fact that the AI provider was fed the lower level in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making your minimum level actually stick
&lt;/h2&gt;

&lt;p&gt;Remove the provider override if you do not want it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Logging"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"LogLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or explicitly align it so future contributors see intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Logging"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"LogLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ApplicationInsights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"LogLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you prefer a programmatic assertion:&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;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;
    &lt;span class="n"&gt;Microsoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationInsights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationInsightsLoggerProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;category&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;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine grained category tuning still works:&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;Logging&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Microsoft"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MyApp.NoisyComponent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Microsoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationInsights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationInsightsLoggerProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"MyApp.Important"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you must drop lower severity traces after they enter the pipeline (for example, during a temporary diagnostic burst), a telemetry processor can discard them, but remember you are paying the cost of generating 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MinimumSeverityProcessor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ITelemetryProcessor&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;readonly&lt;/span&gt; &lt;span class="n"&gt;ITelemetryProcessor&lt;/span&gt; &lt;span class="n"&gt;_next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;MinimumSeverityProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ITelemetryProcessor&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="n"&gt;_next&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ITelemetry&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&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;item&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;TraceTelemetry&lt;/span&gt; &lt;span class="n"&gt;tt&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="n"&gt;tt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SeverityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="n"&gt;tt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SeverityLevel&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;SeverityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;)&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="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="nf"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;Registering a small factory (pattern varies by version) wires it in. Use this sparingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify instead of assuming
&lt;/h2&gt;

&lt;p&gt;Add console logging side by side. Emit one log at every level:&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;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogTrace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"T"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogDebug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"D"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"I"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"W"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"E"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogCritical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"C"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Azure Logs query editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traces
| where message in ("T","D","I","W","E","C")
| project timestamp, message, severityLevel
| order by timestamp desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see Information rows and you did not intend to, search your configuration for a provider or category override before blaming sampling or the SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal repro and its repair
&lt;/h2&gt;

&lt;p&gt;Broken:&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;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;AddApplicationInsightsTelemetry&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ClearProviders&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddApplicationInsights&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddConsole&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddInMemoryCollection&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;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Logging:LogLevel:Default"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Logging:ApplicationInsights:LogLevel:Default"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Information"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// silent lowering&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;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&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="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This still goes to AI"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This also goes to AI"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Hi"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repaired:&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;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddInMemoryCollection&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;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Logging:LogLevel:Default"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Warning"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or declare the intended loglevel again via &lt;code&gt;AddFilter&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful quick snippets
&lt;/h2&gt;

&lt;p&gt;An appsettings template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ApplicationInsights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionString"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"InstrumentationKey=00000000-0000-0000-0000-000000000000"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Logging"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"LogLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"MyApp.ImportantArea"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Information"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A runtime toggle (handy for experiments, not production best practice):&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;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/toggle-ai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TelemetryConfiguration&lt;/span&gt; &lt;span class="n"&gt;cfg&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="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisableTelemetry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisableTelemetry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;$"Telemetry enabled: &lt;/span&gt;&lt;span class="p"&gt;{!&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisableTelemetry&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When a provider override is the right choice
&lt;/h2&gt;

&lt;p&gt;You might intentionally gather richer telemetry centrally while keeping local console lean. You might temporarily elevate verbosity during an incident. You might selectively keep a high value category at Information in AI while leaving everything else at Warning. All valid, provided the difference is deliberate and documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final pre deploy pass
&lt;/h2&gt;

&lt;p&gt;Read through your combined logging configuration. Search for &lt;code&gt;ApplicationInsights&lt;/code&gt; under &lt;code&gt;Logging&lt;/code&gt;. Confirm any category lines that lower severity truly need to. Confirm sampling configuration matches what you expect. Issue a burst of test logs and verify what surfaces in the portal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;Application Insights .NET SDK repository:&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/microsoft/ApplicationInsights-dotnet" rel="noopener noreferrer"&gt;https://github.com/microsoft/ApplicationInsights-dotnet&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Official logging docs:&lt;br&gt;&lt;br&gt;
&lt;a href="https://learn.microsoft.com/aspnet/core/fundamentals/logging/" rel="noopener noreferrer"&gt;https://learn.microsoft.com/aspnet/core/fundamentals/logging/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sampling guidance:&lt;br&gt;&lt;br&gt;
&lt;a href="https://learn.microsoft.com/azure/azure-monitor/app/sampling" rel="noopener noreferrer"&gt;https://learn.microsoft.com/azure/azure-monitor/app/sampling&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Nothing magical forced those Information traces through. Configuration precedence invited them. Once you internalize the path, debugging level mismatches becomes quick and boring. That is exactly what you want in an observability foundation.&lt;/p&gt;

&lt;p&gt;What are your experiences? Let them know in the comments below&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>logging</category>
      <category>applicationinsights</category>
      <category>observability</category>
    </item>
    <item>
      <title>Using FakeLoggerProvider (and ILoggerFactory) in FastEndpoints</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 05 Sep 2025 12:31:12 +0000</pubDate>
      <link>https://dev.to/eelcolos/using-fakeloggerprovider-and-iloggerfactory-in-fastendpoints-4oj6</link>
      <guid>https://dev.to/eelcolos/using-fakeloggerprovider-and-iloggerfactory-in-fastendpoints-4oj6</guid>
      <description>&lt;p&gt;In the first post we focused on &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt; for simple, targeted logger assertions.&lt;/p&gt;

&lt;p&gt;This follow-up goes a layer deeper: capturing &lt;em&gt;all&lt;/em&gt; logging via &lt;code&gt;FakeLoggerProvider&lt;/code&gt;, optionally wiring it into an &lt;code&gt;ILoggerFactory&lt;/code&gt;, and asserting over snapshots (not just the latest record). This is especially useful when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple categories log during a request&lt;/li&gt;
&lt;li&gt;Ordering matters (e.g. warning → error path)&lt;/li&gt;
&lt;li&gt;You want to assert absence or presence of specific patterns&lt;/li&gt;
&lt;li&gt;You're testing pipeline-style frameworks (here: FastEndpoints)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All examples use &lt;code&gt;.NET 8+&lt;/code&gt; and &lt;code&gt;Microsoft.Extensions.Diagnostics.Testing&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to use FakeLoggerProvider instead of FakeLogger
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Just assert one logger category&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observe everything (multiple categories)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FakeLoggerProvider&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need a factory (framework constructs loggers)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ILoggerFactory&lt;/code&gt; + provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query all log records with LINQ&lt;/td&gt;
&lt;td&gt;&lt;code&gt;provider.Collector.GetSnapshot()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;FakeLoggerProvider&lt;/code&gt; acts like any other logging provider. It gathers every &lt;code&gt;LogRecord&lt;/code&gt;. You can get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Collector.LatestRecord&lt;/code&gt; (single)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Collector.GetSnapshot()&lt;/code&gt; (immutable list)&lt;/li&gt;
&lt;li&gt;Structured state (&lt;code&gt;record.StateValues&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Exceptions (&lt;code&gt;record.Exception&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Scopes (&lt;code&gt;record.Scopes&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Minimal example (provider + factory)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FastEndpoints test sample&lt;/span&gt;
&lt;span class="c1"&gt;// NuGet: Microsoft.Extensions.Diagnostics.Testing&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;FastEndpoints&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;Microsoft.Extensions.DependencyInjection&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;Microsoft.Extensions.Logging&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;Microsoft.Extensions.Logging.Testing&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;Xunit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Demo.LoggingTests&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;class&lt;/span&gt; &lt;span class="nc"&gt;GetThingEndpoint&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EndpointWithoutRequest&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;&amp;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;readonly&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GetThingEndpoint&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;GetThingEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GetThingEndpoint&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&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;_log&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;log&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;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/things/{id}"&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;override&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;HandleAsync&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;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Route&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;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Empty id provided"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;ThrowError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Fetching thing {ThingId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogDebug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Repository call starting"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Returning thing {ThingId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt; &lt;span class="n"&gt;cancellation&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;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetThingEndpointTests&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Logs_All_Expected_Messages_In_Order&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;provider&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;FakeLoggerProvider&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;ep&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GetThingEndpoint&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;svc&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTestServices&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;=&amp;gt;&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="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="c1"&gt;// If the framework builds loggers via ILoggerFactory, supply one:&lt;/span&gt;
                &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&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;LoggerFactory&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerProvider&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;await&lt;/span&gt; &lt;span class="n"&gt;ep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ABC123"&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;None&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;records&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSnapshot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&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="n"&gt;CategoryName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetThingEndpoint&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;FullName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&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="n"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&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;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&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;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="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Level&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;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="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Level&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;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="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt;&lt;span class="p"&gt;));&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;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&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="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Returning thing"&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;structured&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;First&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="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Fetching thing"&lt;/span&gt;&lt;span class="p"&gt;));&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;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;structured&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StateValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&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="s"&gt;"ThingId"&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&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;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"ABC123"&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;Key points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We register &lt;code&gt;FakeLoggerProvider&lt;/code&gt; as &lt;code&gt;ILoggerProvider&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;We create a concrete &lt;code&gt;LoggerFactory&lt;/code&gt; so anything resolving &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; via factory still flows through our provider.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Collector.GetSnapshot()&lt;/code&gt; gives an immutable list—safe to query multiple times.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Using LatestRecord vs GetSnapshot
&lt;/h2&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;last&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LatestRecord&lt;/span&gt;&lt;span class="p"&gt;;&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;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;);&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="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Level&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Full list (ordered by arrival)&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSnapshot&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;warnings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&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="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;LatestRecord&lt;/code&gt; when you just expect &lt;em&gt;one&lt;/em&gt; or want a quick “something logged” assertion. Prefer snapshots plus LINQ for clarity when order or filtering matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling no logs (expected or defensive)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FakeLoggerProvider.Collector.LatestRecord&lt;/code&gt; throws &lt;code&gt;InvalidOperationException&lt;/code&gt; if nothing was logged yet. Catch/Assert when verifying &lt;em&gt;absence&lt;/em&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;ex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Throws&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;&amp;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;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LatestRecord&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&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="s"&gt;"No records logged."&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or simply:&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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSnapshot&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Asserting warnings for not-found cases (mirroring the example)
&lt;/h2&gt;

&lt;p&gt;From the original test style:&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;warning&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSnapshot&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="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="n"&gt;Level&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warning&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
                         &lt;span class="n"&gt;r&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="s"&gt;$"Unable to find user by name &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&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;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Structured state and scopes
&lt;/h2&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;rec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Collector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSnapshot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Single&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="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Fetching thing"&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;thingId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StateValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Single&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kv&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kv&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="s"&gt;"ThingId"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&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="s"&gt;"ABC123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thingId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Scopes (if code used BeginScope)&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;scope&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scopes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// scope is a formatted scope string or key/value pair sequence&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  When you must register ILoggerFactory
&lt;/h2&gt;

&lt;p&gt;Some frameworks (or helper libs) explicitly request &lt;code&gt;ILoggerFactory&lt;/code&gt;. If you only register &lt;code&gt;ILoggerProvider&lt;/code&gt;, they create their own factory and your provider may be skipped. Supplying:&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;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&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;LoggerFactory&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ensures a single pipeline. (If you add multiple providers, pass them all in the array.)&lt;/p&gt;

&lt;p&gt;I often start with &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt; and switch to provider + factory the moment I need to assert more than one message or category.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No logs captured&lt;/td&gt;
&lt;td&gt;Ensure provider registered before endpoint created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LatestRecord throws&lt;/td&gt;
&lt;td&gt;Use GetSnapshot() for empties&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong category filtered&lt;/td&gt;
&lt;td&gt;Compare &lt;code&gt;record.CategoryName&lt;/code&gt; to &lt;code&gt;typeof(TheType).FullName&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured value missing&lt;/td&gt;
&lt;td&gt;Ensure you used named template: LogInformation("Id {ThingId}", id)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Quick FastEndpoints factory helper (optional)
&lt;/h2&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;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoggingTestSetup&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEndpoint&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FakeLoggerProvider&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CreateWithLogging&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TEndpoint&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;TEndpoint&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IEndpoint&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;provider&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;FakeLoggerProvider&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;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TEndpoint&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;svc&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTestServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&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;LoggerFactory&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILoggerProvider&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;return&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="n"&gt;provider&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;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FakeLoggerProvider&lt;/code&gt; elevates log testing from “one-off assert” to “full pipeline visibility.” Pairing it with an explicit &lt;code&gt;ILoggerFactory&lt;/code&gt; gives deterministic, framework-friendly logging in test hosts (including FastEndpoints). Use it when you outgrow the single-category simplicity of &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Happy testing.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>testing</category>
      <category>logging</category>
      <category>fastendpoints</category>
    </item>
    <item>
      <title>Better ILogger testing in .NET</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 22 Aug 2025 11:48:18 +0000</pubDate>
      <link>https://dev.to/eelcolos/better-ilogger-testing-in-net-2n1b</link>
      <guid>https://dev.to/eelcolos/better-ilogger-testing-in-net-2n1b</guid>
      <description>&lt;p&gt;Logging in .NET is solid in production, but when it comes to unit and integration tests… things get messy.&lt;br&gt;
I’ve often found myself skipping over logger checks because mocking &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; felt brittle or just not worth the effort.&lt;/p&gt;

&lt;p&gt;But here’s the thing: logs aren’t just background noise. They’re often the &lt;em&gt;first place you look&lt;/em&gt; when something goes wrong in production. If the logging isn’t there, or if it’s wrong, you’ll notice it the hard way.&lt;/p&gt;

&lt;p&gt;In this post, I’ll walk through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why it’s actually worth checking logs in tests&lt;/li&gt;
&lt;li&gt;Why mocking loggers is frustrating&lt;/li&gt;
&lt;li&gt;How &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt; makes this easy, with examples you can drop straight into your tests (&lt;a href="https://www.nuget.org/packages/Microsoft.Extensions.Diagnostics.Testing/" rel="noopener noreferrer"&gt;Nuget&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Why check logging?
&lt;/h2&gt;

&lt;p&gt;Logs are your safety net. They give you visibility into what your app was doing when something went sideways. Without them, debugging turns into guesswork.&lt;/p&gt;

&lt;p&gt;A few reasons to test your logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Catch silly mistakes early, like logging the wrong variable, or writing unreadable object dumps, like &lt;code&gt;[Object object]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make sure log levels make sense. &lt;code&gt;LogError&lt;/code&gt; vs &lt;code&gt;LogDebug&lt;/code&gt; matters when monitoring and alerting are wired up.&lt;/li&gt;
&lt;li&gt;Keep operational contracts intact. Logs are consumed by dashboards, pipelines, and alerting systems. Breaking them silently can hurt in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So while it’s tempting to skip logging checks in tests, a little validation here goes a long way.&lt;/p&gt;
&lt;h2&gt;
  
  
  Mocking &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; feels awkward
&lt;/h2&gt;

&lt;p&gt;If you’ve ever tried mocking ILogger with Moq, NSubstitute, or FakeItEasy, you probably know the pain.&lt;/p&gt;

&lt;p&gt;For example, to me, FakeItEasy states: if you use &lt;code&gt;A.Fake&lt;/code&gt; on any interface, it'll create something usable. However, when using&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;logger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fake&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DemoClass&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then try and run it, it'll show:&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  FakeItEasy error output
  &lt;br&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to create fake of type Microsoft.Extensions.Logging.ILogger`1:
    No usable default constructor was found on the type Microsoft.Extensions.Logging.ILogger`1.
    An exception of type System.ArgumentException was caught during this call. Its message was:
    Can not create proxy for type Microsoft.Extensions.Logging.ILogger`1 because the target type is not accessible. 
    Make it public, or internal and mark your assembly with 
    [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=...")] 
    attribute, because assembly Microsoft.Extensions.Logging.Abstractions is strong-named. 
    (Parameter 'additionalInterfacesToProxy')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Another way is shown at Moq (from &lt;a href="https://www.freecodecamp.org/news/how-to-use-fakelogger-to-make-testing-easier-in-net/" rel="noopener noreferrer"&gt;https://www.freecodecamp.org/news/how-to-use-fakelogger-to-make-testing-easier-in-net/&lt;/a&gt; ):&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Moq NotSupported example
  &lt;br&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Arrange&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mockLogger&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;Mock&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// pass the mockedLogger to our service&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orderService&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;OrderService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;mockLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&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;Mock&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IInvoiceService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Object&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;customerId&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="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="n"&gt;Order&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ID&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="n"&gt;CustomerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Products&lt;/span&gt; &lt;span class="p"&gt;=&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;Product&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ID&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="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Ping pong balls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1.00M&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="n"&gt;OrderDate&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="p"&gt;};&lt;/span&gt;

        &lt;span class="c1"&gt;// Act&lt;/span&gt;
        &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessOrder&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;// Assert&lt;/span&gt;
        &lt;span class="n"&gt;mockLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Processing order..."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Once&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;mockLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Order processed successfully."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Once&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;which shows&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; System.NotSupportedException: 
Unsupported expression: x =&amp;gt; x.LogInformation("Processing order...", new[] {  })
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Microsoft recognized how painful logging tests could be in real-world services. Mocking &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; often led to brittle tests or convoluted setups. To address this, they introduced &lt;strong&gt;&lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/strong&gt; in .NET 8, as part of the testing-friendly tooling described in &lt;a href="https://devblogs.microsoft.com/dotnet/fake-it-til-you-make-it-to-production/" rel="noopener noreferrer"&gt;“Fake It Til You Make It…To Production”&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt; is tiny, test-oriented, and captures logs in memory. You can assert log messages, levels, exceptions, and even structured state without relying on Moq, FakeItEasy, or reflection hacks.&lt;/p&gt;

&lt;p&gt;Here’s a minimal example:&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;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.Logging&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;Microsoft.Extensions.Logging.Testing&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;Xunit&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;class&lt;/span&gt; &lt;span class="nc"&gt;MyServiceTests&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&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;DoWork_LogsExpectedMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Arrange: create a FakeLogger for MyService&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fakeLogger&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;FakeLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;service&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;MyService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fakeLogger&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// inject logger into service&lt;/span&gt;

        &lt;span class="c1"&gt;// Act&lt;/span&gt;
        &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DoWork&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Assert: verify log message&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;fakeLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LatestRecord&lt;/span&gt;&lt;span class="p"&gt;;&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;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;);&lt;/span&gt;
        &lt;span class="nc"&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="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;);&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;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Work done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Message&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;With &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt;, you can quickly verify your logs without wrestling with generic delegates or fragile mock setups. For more complex scenarios, it also exposes all captured &lt;code&gt;LogRecord&lt;/code&gt;s, which is handy for asserting multiple messages or structured logging.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full integration-test example (xUnit + WebApplicationFactory)
&lt;/h3&gt;

&lt;p&gt;Here’s a complete integration test that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates a &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt; for the category &lt;code&gt;T&lt;/code&gt; you care about (replace &lt;code&gt;MyService&lt;/code&gt; with the concrete type used as &lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; in your app).&lt;/li&gt;
&lt;li&gt;Injects that instance into the test host via &lt;code&gt;WithWebHostBuilder&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Calls an endpoint.&lt;/li&gt;
&lt;li&gt;Retrieves the registered logger from the test host and asserts using &lt;code&gt;LatestRecord&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&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;System.Linq&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;System.Threading.Tasks&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;FluentAssertions&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;Microsoft.AspNetCore.Mvc.Testing&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;Microsoft.Extensions.DependencyInjection&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;Microsoft.Extensions.Logging&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;Microsoft.Extensions.Logging.Testing&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;Xunit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Replace `Program` with the class that defines your app's host entry (top-level Program in minimal APIs)&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BudgetIntegrationTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;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;readonly&lt;/span&gt; &lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_factory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;BudgetIntegrationTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&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;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;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;GetBudget_ReturnsFile_AndLogsMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Replace `MyService` with the concrete type T used in ILogger&amp;lt;T&amp;gt;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fakeLogger&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;FakeLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Create a factory that injects our fake logger into DI&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithWebHostBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&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;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureServices&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;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Remove any existing ILogger&amp;lt;MyService&amp;gt; registration (if present)&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;existing&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;SingleOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceType&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;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;existing&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;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="c1"&gt;// Register our fake logger as the ILogger&amp;lt;MyService&amp;gt; implementation&lt;/span&gt;
                &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;((&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt;&lt;span class="n"&gt;fakeLogger&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="c1"&gt;// Create client and call the endpoint that triggers the log&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;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&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;response&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/budget/download"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// adjust path to your endpoint&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Retrieve the registered logger from the test host and assert&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FakeLogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt;&lt;span class="n"&gt;factory&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="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MyService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LatestRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;NotBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LatestRecord&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="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Contain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Returning budget file in"&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;Notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MyService&lt;/code&gt; should be replaced with the type used when your code calls &lt;code&gt;logger.LogInformation(...)&lt;/code&gt; or similar (often the service or controller class).&lt;/li&gt;
&lt;li&gt;If the app registers loggers differently (or uses factory-style providers), removing the existing descriptor ensures your fake gets picked up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LatestRecord&lt;/code&gt; will be &lt;code&gt;null&lt;/code&gt; if nothing was logged. Use assertions accordingly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ILogger&amp;lt;T&amp;gt;&lt;/code&gt; is fantastic for production observability, but not designed to be easily asserted in tests. Instead of wrestling with &lt;code&gt;Log&amp;lt;TState&amp;gt;&lt;/code&gt; signatures or brittle mock setups, &lt;code&gt;FakeLogger&amp;lt;T&amp;gt;&lt;/code&gt; gives you a simple, inspectable test surface and keeps tests readable.&lt;/p&gt;

&lt;p&gt;I added a small FakeLogger demo that shows how to capture and assert ILogger output&lt;br&gt;
from a .NET service in unit tests. See the code and run the tests locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/EelcoLos/nx-tinkering" rel="noopener noreferrer"&gt;https://github.com/EelcoLos/nx-tinkering&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Implementation &amp;amp; review (merged PR): &lt;a href="https://github.com/EelcoLos/nx-tinkering/pull/726" rel="noopener noreferrer"&gt;https://github.com/EelcoLos/nx-tinkering/pull/726&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Exact file (on main):
&lt;a href="https://github.com/EelcoLos/nx-tinkering/blob/main/apps/fakelogger-demo.Test/MyServiceTests.cs" rel="noopener noreferrer"&gt;https://github.com/EelcoLos/nx-tinkering/blob/main/apps/fakelogger-demo.Test/MyServiceTests.cs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run locally (PowerShell)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;dotnet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\apps\fakelogger-demo.Test\fakelogger-demo.Test.csproj&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Happy testing! 🚀&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>testing</category>
      <category>logging</category>
    </item>
    <item>
      <title>Lessons Learned Shipping .NET Apps with Docker, Alpine, and Kubernetes</title>
      <dc:creator>Eelco Los</dc:creator>
      <pubDate>Fri, 27 Jun 2025 09:36:18 +0000</pubDate>
      <link>https://dev.to/eelcolos/lessons-learned-shipping-net-apps-with-docker-alpine-and-kubernetes-4n59</link>
      <guid>https://dev.to/eelcolos/lessons-learned-shipping-net-apps-with-docker-alpine-and-kubernetes-4n59</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🛠️ &lt;strong&gt;Update (4th of July, 2025):&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Microsoft’s official .NET 8+ base images now define a secure numeric user via &lt;code&gt;APP_UID&lt;/code&gt;.&lt;br&gt;&lt;br&gt;
If you're using those, prefer &lt;code&gt;USER $APP_UID&lt;/code&gt; over manually creating &lt;code&gt;appuser&lt;/code&gt;.&lt;br&gt;&lt;br&gt;
I’ve updated the Docker Security section to reflect this recommended approach while keeping the broader &lt;code&gt;appuser&lt;/code&gt; pattern for non-Microsoft base images.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I love containerization.&lt;/p&gt;

&lt;p&gt;From personal projects running at home to production-grade services, containers have transformed the way I build and ship software. They're lightweight, consistent, and (when used correctly) secure. For &lt;strong&gt;local development&lt;/strong&gt;, I usually prefer to work with full SDKs. But for deployments, I lean heavily on containers, DevContainers, and GitHub Actions.&lt;/p&gt;

&lt;p&gt;This post will walk you through a solid workflow for building and running &lt;strong&gt;.NET apps in Docker using Alpine&lt;/strong&gt;, preparing images with CI, and tuning for &lt;strong&gt;Kubernetes deployments&lt;/strong&gt; with realistic resource limits.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧊 Running .NET in Alpine Containers
&lt;/h2&gt;

&lt;p&gt;Alpine is a super minimal Linux distro that makes for compact Docker images. Microsoft ships Alpine-based variants of .NET like this (at the time of writing this is &lt;code&gt;dotnet&lt;/code&gt; 9):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; mcr.microsoft.com/dotnet/aspnet:9.0-alpine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To have a minimal container is what I feel containerization is really about: work with the OS that is minimal in scope and just focuses on the app execution.&lt;br&gt;
But there's a gotcha: &lt;strong&gt;cultural and timezone data&lt;/strong&gt; isn’t included by default. To make your app work correctly across locales and timezones, add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; icu-libs tzdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;➡️ See &lt;a href="https://andrewlock.net/dotnet-core-docker-and-cultures-solving-culture-issues-porting-a-net-core-app-from-windows-to-linux/" rel="noopener noreferrer"&gt;Andrew Lock’s excellent guide&lt;/a&gt; for deeper insights on this issue.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛡️ Docker Security: Running as Non-Root (And Doing It Right)
&lt;/h2&gt;

&lt;p&gt;One of the most common but overlooked Docker security pitfalls is that containers run as &lt;code&gt;root&lt;/code&gt; &lt;strong&gt;by default&lt;/strong&gt;. If someone breaks out of your app process, they’re root inside the container. And that's bad news.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining the Non-Root User
&lt;/h3&gt;

&lt;p&gt;Start by creating a lightweight user and group in the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-S&lt;/span&gt; appgroup &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-S&lt;/span&gt; appuser &lt;span class="nt"&gt;-G&lt;/span&gt; appgroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-S&lt;/code&gt; creates system users/groups (no home directory, no password).&lt;/li&gt;
&lt;li&gt;This keeps the image small and secure.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🔎 &lt;strong&gt;Note on &lt;code&gt;$APP_UID&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;$APP_UID&lt;/code&gt; pattern is a &lt;strong&gt;Microsoft documented convention&lt;/strong&gt; introduced in .NET 8+ base images. These images define a numeric non-root user internally and expose its UID via the &lt;code&gt;APP_UID&lt;/code&gt; environment variable. This makes it easy to write:&lt;/p&gt;


&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; $APP_UID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;If you're using a &lt;strong&gt;non-Microsoft base image&lt;/strong&gt; (like Alpine or Debian), this variable won't exist unless you define it yourself:&lt;/p&gt;


&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; APP_UID=10001&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; $APP_UID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;So while the pattern is technically portable, &lt;strong&gt;only Microsoft's .NET base images provide it by default&lt;/strong&gt;. For broader compatibility, the traditional &lt;code&gt;adduser appuser &amp;amp;&amp;amp; USER appuser&lt;/code&gt; pattern is still widely used and understood. Read more about Microsofts recommendation at &lt;a href="https://devblogs.microsoft.com/dotnet/securing-containers-with-rootless/#using-app" rel="noopener noreferrer"&gt;https://devblogs.microsoft.com/dotnet/securing-containers-with-rootless/#using-app&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Secure File Ownership: Use &lt;code&gt;COPY --chown&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;I used to rely on fixing permissions like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ./build/api .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; appuser:appgroup .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this isn’t ideal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adds an extra layer.&lt;/li&gt;
&lt;li&gt;Slower on large file sets.&lt;/li&gt;
&lt;li&gt;Messy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I learned to &lt;strong&gt;assign ownership directly at copy time&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=appuser:appgroup ./build/api .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instantly assigns correct ownership.&lt;/li&gt;
&lt;li&gt;Avoids extra &lt;code&gt;RUN chown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Makes your &lt;code&gt;Dockerfile&lt;/code&gt; cleaner and more declarative.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Putting It Together
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; mcr.microsoft.com/dotnet/aspnet:9.0-alpine&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-S&lt;/span&gt; appgroup &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-S&lt;/span&gt; appuser &lt;span class="nt"&gt;-G&lt;/span&gt; appgroup

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=appuser:appgroup ./build/api .&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=appuser:appgroup entrypoint.sh .&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; appuser&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["./entrypoint.sh"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The app runs with the &lt;strong&gt;least privilege&lt;/strong&gt; necessary.&lt;/li&gt;
&lt;li&gt;Files are owned properly the moment they're brought into the image.&lt;/li&gt;
&lt;li&gt;Clean. Predictable. Secure.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🛠️ CI Builds Artifacts, Docker Just Packages
&lt;/h2&gt;

&lt;p&gt;One of the best things you can do is &lt;strong&gt;keep your Dockerfile lean&lt;/strong&gt;. Not only to avoid compiling inside Docker: it bloats your image and slows builds, but also because of the what docker is for: containerizing your application. Therefore, your app should be ready to be containerized. That is, how I experience Docker to primarily be: the 'containerizer'. So, to build then, use your CI pipeline to &lt;strong&gt;build and publish the app&lt;/strong&gt;, then use Docker to package the output. This will give you inspectable artifacts of the build that.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔧 GitHub Actions: Build and Upload Artifacts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&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;Build and Publish&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;dotnet publish -o ${{ env.PUBLISH_FOLDER_NAME }} ${{ inputs.publish-args }}&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 Build Artifact&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;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;${{ inputs.artifact-name }}&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;${{ inputs.project-folder }}/${{ env.PUBLISH_FOLDER_NAME }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in your Docker build step, pull the artifacts back down:&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="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;Download artifacts&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;IFS=',' read -ra artifacts &amp;lt;&amp;lt;&amp;lt; "${{ inputs.download-artifact }}"&lt;/span&gt;
    &lt;span class="s"&gt;for artifact in "${artifacts[@]}"; do&lt;/span&gt;
      &lt;span class="s"&gt;mkdir -p "${{ inputs.working-directory }}/build/$artifact"&lt;/span&gt;
      &lt;span class="s"&gt;gh run download --name "$artifact" --dir "${{ inputs.working-directory }}/build/$artifact"&lt;/span&gt;
    &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, build and push the image:&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="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;Build and push&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;docker/build-push-action@v6&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;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.DOCKERFILE }}&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.working-directory }}&lt;/span&gt;
    &lt;span class="na"&gt;push&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;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.container-tags }}&lt;/span&gt;
    &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
    &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;strong&gt;artifact-first approach&lt;/strong&gt; gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reproducibility&lt;/li&gt;
&lt;li&gt;Cleaner build caching&lt;/li&gt;
&lt;li&gt;Easy debugging (you can inspect the build output separately)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ☸️ Kubernetes + Helm: Resource Limits That Actually Work
&lt;/h2&gt;

&lt;p&gt;Let’s be real: .NET isn’t the smallest kid on the block. You can’t slap a tiny resource limit on it without consequences.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔍 What Microsoft Recommends for AKS
&lt;/h3&gt;

&lt;p&gt;Microsoft’s official guidance for &lt;strong&gt;AKS&lt;/strong&gt; firmly states:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Set pod requests and limits on all pods in your YAML manifests. If the AKS cluster uses resource quotas and you don't define these values, your deployment may be rejected.”&lt;/strong&gt;&lt;br&gt;
— AKS Best Practices (resource requests &amp;amp; limits) &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They further caution:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Pod CPU and memory limits define the maximum amount of CPU and memory a pod can use… avoid setting a pod limit higher than your nodes can support.”&lt;/strong&gt;&lt;br&gt;
— AKS Best Practices (resource guidelines) &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Microsoft also provides a &lt;strong&gt;default starting configuration&lt;/strong&gt; in their examples:&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Mi&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250m&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn’t a strict minimum. But it is a &lt;strong&gt;realistic baseline&lt;/strong&gt; that balances scheduling, performance, and cost .&lt;/p&gt;




&lt;h3&gt;
  
  
  My .NET-Focused Configuration
&lt;/h3&gt;

&lt;p&gt;Here’s the setup that consistently works for .NET workloads I test:&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20Mi&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;175Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ While &lt;code&gt;.NET&lt;/code&gt; can technically run with ~125 Mi memory, in practice this leads to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sluggish cold starts&lt;/li&gt;
&lt;li&gt;Failing health probes&lt;/li&gt;
&lt;li&gt;Garbage collector thrash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pushing memory to &lt;strong&gt;175 Mi&lt;/strong&gt; ensures decent startup times and runtime stability.&lt;/p&gt;




&lt;h3&gt;
  
  
  ⚖️ TL;DR Recommendations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always&lt;/strong&gt; define both &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; Setting &lt;code&gt;limit &amp;gt; request&lt;/code&gt; improves stability: start around 175 Mi for .NET&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; A reasonable request (~100m) with a higher limit helps performance without causing throttling&lt;/li&gt;
&lt;li&gt;These aren’t arbitrary: they reflect &lt;strong&gt;Microsoft’s AKS baseline examples&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/en-us/azure/aks/best-practices-app-cluster-reliability?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;Deployment and cluster reliability best practices for Azure&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/en-us/azure/aks/developer-best-practices-resource-management?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;Resource management best practices for Azure Kubernetes Service&lt;/a&gt;, &lt;a href="https://docs.azure.cn/en-us/aks/best-practices-app-cluster-reliability?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;Deployment and cluster reliability best practices for Azure&lt;/a&gt;, &lt;a href="https://stackoverflow.com/questions/68561083/what-is-the-best-practice-to-have-request-and-limit-values-to-a-pod-in-k8s?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;What is the best practice to have request and limit values to a pod in&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🎯 Final Thoughts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use small sized base images, like Alpine, but patch it with you needs (ie: &lt;code&gt;icu-libs&lt;/code&gt; and &lt;code&gt;tzdata&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Run as a non-root user inside your Docker containers&lt;/li&gt;
&lt;li&gt;Use CI to build the app, and let Docker just package it&lt;/li&gt;
&lt;li&gt;Tune your K8s Helm charts to keep .NETs footprint small, but still responsive under pressure of your required workload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Containers are amazing, but they're even better when treated with care. With these practices, you’ll ship faster, safer, and smarter—whether it's production, staging, or even your home lab.&lt;/p&gt;




&lt;p&gt;Got questions or tweaks to share? Drop them in the comments—I'd love to hear your workflow!&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>githubactions</category>
    </item>
  </channel>
</rss>
