<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://www.devopswes.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.devopswes.com/" rel="alternate" type="text/html" /><updated>2026-05-09T18:50:29+00:00</updated><id>https://www.devopswes.com/feed.xml</id><title type="html">DevOpsWes</title><subtitle>Practical insights on AI, DevOps, Cloud, Automation and Virtual Desktop Environments — from a practitioner navigating the transformation with you.</subtitle><author><name>Wesly van Straten</name><email>wes.vanstraten@gmail.com</email></author><entry><title type="html">Welcome to DevOpsWes: why I finally started writing things down</title><link href="https://www.devopswes.com/2026/04/20/welcome-to-devopswes/" rel="alternate" type="text/html" title="Welcome to DevOpsWes: why I finally started writing things down" /><published>2026-04-20T00:00:00+00:00</published><updated>2026-04-20T00:00:00+00:00</updated><id>https://www.devopswes.com/2026/04/20/welcome-to-devopswes</id><content type="html" xml:base="https://www.devopswes.com/2026/04/20/welcome-to-devopswes/"><![CDATA[<aside class="post-tldr" aria-label="TL;DR summary">
  <div class="post-tldr-inner">
    <span class="post-tldr-label">TL;DR</span>
    <p class="post-tldr-text">20 years in IT and I never blogged. Writing felt like work I wasn't being paid for. Then AI started moving fast enough that the only way I can keep up is to write things down, so I figured I'd share it.</p>
  </div>
</aside>

<p>I’ve been in IT for over 20 years. Started in 2001, fixing printers and managing Citrix environments that nobody fully understood, including the people who built them. Spent the next decade going deeper into virtual desktops, workspace management, and PowerShell scripts that kept breaking in creative new ways.</p>

<p>Then somewhere around 2019 I properly crossed over into DevOps. Terraform, Azure Pipelines, containers, the whole thing. And now here in 2026 I work as a DevOps Engineer delivering automated virtual workspace platforms at Nationale-Nederlanden, while trying to make sense of what AI is actually doing to this industry.</p>

<p>That last part is why I’m writing this.</p>

<p>I’ve never been much of a blogger. Writing felt like work I wasn’t being paid for. But the AI thing broke that logic, because everything is moving so fast that the only way I can keep up with what I’m learning is to write it down. And if I’m writing it anyway, I might as well share it.</p>

<p>So that’s the deal here. I’m a practitioner, not an influencer. I write about things I’ve used and the problems that came with them. If something doesn’t work, I’ll say so. If I’m not sure yet, I’ll say that too.</p>

<p><strong>What you’ll find here:</strong></p>

<p>AI that goes beyond chatbots: real integrations, agents, and local models that do actual work. DevOps and Azure content from someone who lives in Azure DevOps pipelines daily. Automation, PowerShell, cloud architecture, and virtual desktop environments. The good, the weird, and the occasionally baffling.</p>

<p>If that sounds useful, stick around. There’s a lot I want to write about.</p>

<p><a href="/feed.xml">RSS feed here</a> if you want to keep up without algorithms deciding what you see.</p>]]></content><author><name>Wes</name></author><category term="AI" /><category term="DevOps" /><category term="Automation" /><summary type="html"><![CDATA[20 years in IT and I never bothered blogging. Then AI showed up and changed the math.]]></summary></entry><entry><title type="html">How I used Copilot to find a VDI bug that left no errors in any log</title><link href="https://www.devopswes.com/2026/04/15/ai-transforming-devops-2026/" rel="alternate" type="text/html" title="How I used Copilot to find a VDI bug that left no errors in any log" /><published>2026-04-15T00:00:00+00:00</published><updated>2026-04-15T00:00:00+00:00</updated><id>https://www.devopswes.com/2026/04/15/ai-transforming-devops-2026</id><content type="html" xml:base="https://www.devopswes.com/2026/04/15/ai-transforming-devops-2026/"><![CDATA[<aside class="post-tldr" aria-label="TL;DR summary">
  <div class="post-tldr-inner">
    <span class="post-tldr-label">TL;DR</span>
    <p class="post-tldr-text">A Citrix DaaS issue with no errors in any log. I used a ControlUp trigger to automatically capture 40 minutes of forensic data around each occurrence, then briefed Copilot to cross-reference it against a clean baseline with an explicit elimination step. The root cause was hiding in an informational event: two Azure auth module versions loaded simultaneously, neither failing. You wouldn't have found it reading logs by hand.</p>
  </div>
</aside>

<ul id="markdown-toc">
  <li><a href="#the-setup-controlup-as-a-data-trap" id="markdown-toc-the-setup-controlup-as-a-data-trap">The setup: ControlUp as a data trap</a></li>
  <li><a href="#the-problem-too-much-data-and-no-error-to-chase" id="markdown-toc-the-problem-too-much-data-and-no-error-to-chase">The problem: too much data, and no error to chase</a></li>
  <li><a href="#giving-copilot-an-investigation-brief" id="markdown-toc-giving-copilot-an-investigation-brief">Giving Copilot an investigation brief</a></li>
  <li><a href="#what-copilot-found" id="markdown-toc-what-copilot-found">What Copilot found</a></li>
  <li><a href="#what-made-this-work" id="markdown-toc-what-made-this-work">What made this work</a></li>
  <li><a href="#try-it-yourself" id="markdown-toc-try-it-yourself">Try it yourself</a></li>
</ul>

<p>We had a problemin our Citrix Cloud DaaS environment that had been annoying people for weeks. Users would hit an issue, we couldn’t figure out why, and we absolutely could not reproduce it on demand. It just happened. Randomly. Then stopped. Then happened again.</p>

<p>No error. No warning. Nothing obvious in any log we checked manually.</p>

<p>Here’s how I eventually cracked it.</p>

<h2 id="the-setup-controlup-as-a-data-trap">The setup: ControlUp as a data trap</h2>

<p>If you work with VDI environments and you’re not using <a href="https://www.controlup.com/">ControlUp</a>, you should look at it. One of its more powerful features is the ability to create automated triggers that fire when something specific happens in the environment.</p>

<p>I set up a trigger to catch the exact Windows Event that was associated with the issue. The moment that event appeared on a machine, ControlUp would fire automatically and collect a forensic snapshot of the session. What I captured:</p>

<ul>
  <li>Full Windows Event Viewer logs from both the Application and System channels</li>
  <li>A complete Windows system information dump</li>
  <li>All installed applications with their version numbers</li>
  <li>Registry keys for FSLogix profile configuration (HKCU and HKLM) and active Group Policy settings</li>
  <li>The FSLogix log files</li>
</ul>

<p>The clever part: the snapshot wasn’t just “right now.” ControlUp captured 20 minutes of data <em>before</em> the trigger event fired, and 20 minutes <em>after</em>. So when it arrived, I had a 40-minute window of everything that happened on that machine around the exact moment the issue occurred.</p>

<p>That’s a lot of data. Which turned out to be both the solution and the problem.</p>

<h2 id="the-problem-too-much-data-and-no-error-to-chase">The problem: too much data, and no error to chase</h2>

<p>I went through the dataset manually. The Application log, the System log, the FSLogix logs. I knew roughly when the event happened, so I was looking for anything suspicious in the surrounding minutes.</p>

<p>Nothing.</p>

<p>No error events. No warnings that made sense. FSLogix was happy. Group Policy had applied cleanly. The system info looked normal. The installed applications looked normal.</p>

<p>This is the kind of situation that drives you a bit mad, because when there’s genuinely nothing to chase, you start second-guessing whether you even have the right event, the right machine, or the right time window. It all looked fine, and that was the problem.</p>

<p>I also had a second dataset: a snapshot from a different machine where the trigger had <em>not</em> fired, collected at roughly the same time. A clean baseline to compare against.</p>

<h2 id="giving-copilot-an-investigation-brief">Giving Copilot an investigation brief</h2>

<p>At this point I decided to try something I hadn’t done before. Instead of using Copilot for code or documentation, I used it as a data analyst.</p>

<p>I opened both datasets in VS Code and wrote out an instruction set. Not a quick prompt, an actual structured brief describing exactly what I wanted it to do:</p>

<ol>
  <li>You are investigating a specific VDI issue that occurred at [timestamp]. The trigger event was [event ID and description].</li>
  <li>Here is Dataset A: the affected machine. Here is Dataset B: a clean machine from the same environment at roughly the same time.</li>
  <li>Correlate all data in Dataset A to the trigger event. Look for anything that is present in Dataset A but absent or different in Dataset B.</li>
  <li>Generate a list of all possible causes you can identify.</li>
  <li>For each possible cause, assess whether the other data in the dataset can rule it out. Remove it from the list if it can be confidently eliminated.</li>
  <li>What’s left after elimination is your prioritised suspect list.</li>
</ol>

<p>Here’s what the file actually looks like. I keep a <code class="language-plaintext highlighter-rouge">copilot-instructions.md</code> in the root of the VS Code workspace alongside the data folders, so Copilot picks it up as context automatically:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh"># Investigation Brief</span>

<span class="gu">## Your role</span>
You are an expert in [technology/platform]. Follow these instructions to find
the root cause of the issue described below.

<span class="gu">## The error</span>
[Paste the full error message and stack trace here — don't summarise it.
Give Copilot the raw text so it can match it against what appears in the logs.]

<span class="gu">## The data</span>
The subfolders in this workspace contain forensic data captured automatically
when the error occurred:
<span class="p">
-</span> <span class="gs">**[MACHINE-AFFECTED]**</span> — data from the machine where the error occurred.
  Captured at [timestamp].
<span class="p">-</span> <span class="gs">**[MACHINE-CLEAN]**</span> — data from a healthy machine in the same environment.
  Captured at roughly the same time, used as a clean baseline.

<span class="gu">## Instructions</span>
<span class="p">1.</span> Find the error occurrence in the data. Note the exact timestamp.
<span class="p">2.</span> Analyse all available data from the affected machine in the 30-60 minutes
   before and after that timestamp.
<span class="p">3.</span> Compare against the clean machine. List everything that is present or
   different in the affected machine's data.
<span class="p">4.</span> For each item on that list: assess whether other data in the dataset can
   rule it out as a cause. If it can be eliminated, remove it and say why.
<span class="p">5.</span> Return the remaining suspects in priority order with the evidence for each.

<span class="gs">**Important:**</span> Include informational events in your analysis, not just errors
and warnings. Root causes often hide there.
</code></pre></div></div>

<p>The file is the brief. Not a chat message — a file that lives in the workspace. That distinction matters: it doesn’t disappear between sessions, you can refine it as the investigation develops, and Copilot reads the current version every time you ask a new question. When I brought in additional machines’ data mid-investigation, I added a new section to the bottom of the file explaining what the new folders contained and asked Copilot to validate whether the fresh data matched its initial conclusions. It did. That’s a different workflow from re-pasting a prompt into a chat window.</p>

<p>The elimination step was deliberate. When you’re debugging something with no obvious error trail, the instinct is to collect more suspects. What you actually need is to throw suspects <em>out</em> faster. Every eliminated cause is investigation time you don’t have to spend.</p>

<h2 id="what-copilot-found">What Copilot found</h2>

<p>It found it.</p>

<p>Buried in the XML data of an informational event (not an error, not a warning, <em>informational</em>) was a record showing that multiple versions of the same Azure authentication module were being loaded simultaneously. Two different versions of the same module, both active at the same time.</p>

<p>No error was generated because neither version <em>failed</em>. They were both loaded. Both running. The actual authentication wasn’t broken. But somewhere downstream, the conflict between the two versions was causing the behaviour users were reporting, and the resulting errors looked like a completely different problem.</p>

<p>This is exactly the kind of thing you don’t find manually because you’re not looking for it. You’re looking for something that <em>broke</em>. This didn’t break. It just quietly did the wrong thing in the background, and the symptoms showed up somewhere else entirely.</p>

<p>The clean dataset confirmed it: the second machine had only one version of the module loaded. Case closed.</p>

<h2 id="what-made-this-work">What made this work</h2>

<p>A few things came together here that are worth pulling apart if you want to try something similar.</p>

<p><strong>The data collection had to be automatic.</strong> If I’d been trying to capture this manually after a report came in, the window would have been long gone. The ControlUp trigger meant the forensic data was already waiting for me when I needed it. Design your data capture for the moment you <em>won’t</em> be there, not the moment you will.</p>

<p><strong>Two datasets are much better than one.</strong> Giving Copilot a clean baseline to compare against completely changed what it could do. Without Dataset B, it would have had to guess what “normal” looked like. With it, differences jumped out immediately. If you’re investigating something intermittent, always try to capture a “working” snapshot alongside the broken one.</p>

<p><strong>The instruction set mattered.</strong> A vague prompt produces a vague answer. Writing out the investigation brief properly, with the elimination step explicitly included, is what produced something useful. Treat it like briefing a junior analyst who is very thorough but needs to be told exactly what you’re after.</p>

<p><strong>Look at informational events.</strong> I’ll be honest: I would not have found this on my own, because I was filtering for errors and warnings. The root cause was hiding in an informational event that I’d have scrolled past without a second thought. If you’re asking AI to investigate logs, tell it explicitly to include informational events in its analysis. The smoking gun might be there.</p>

<h2 id="try-it-yourself">Try it yourself</h2>

<p>The folder structure that makes this work:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_Copilot-Troubleshooting/
├── copilot-instructions.md         ← the brief Copilot reads first
├── MACHINE-AFFECTED_[timestamp]/   ← forensic data from the broken machine
│   ├── Application.evtx
│   ├── System.evtx
│   └── [your app-specific log]
└── MACHINE-CLEAN_[timestamp]/      ← same data from a healthy machine
    ├── Application.evtx
    └── System.evtx
</code></pre></div></div>

<p>Open the whole folder as a VS Code workspace. Copilot’s context covers all open files, so the brief and the data are in the same session without you needing to copy-paste anything manually.</p>

<p>One practical problem: <code class="language-plaintext highlighter-rouge">.evtx</code> files from Windows Event Viewer are binary. Copilot can’t read them directly. If you’re on Windows and want to pull the relevant window of events without loading everything into Event Viewer by hand:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$errorTime</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">datetime</span><span class="p">]</span><span class="s2">"2025-04-15 09:23:00"</span><span class="w">  </span><span class="c"># replace with your error timestamp</span><span class="w">
</span><span class="nv">$windowMins</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">30</span><span class="w">
</span><span class="nv">$evtxPath</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="s2">".\MACHINE-AFFECTED\Application.evtx"</span><span class="w">
</span><span class="nv">$outputPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".\events-around-error.csv"</span><span class="w">

</span><span class="n">Get-WinEvent</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$evtxPath</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="bp">$_</span><span class="o">.</span><span class="nf">TimeCreated</span><span class="w"> </span><span class="o">-ge</span><span class="w"> </span><span class="p">(</span><span class="nv">$errorTime</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="p">(</span><span class="n">New-TimeSpan</span><span class="w"> </span><span class="nt">-Minutes</span><span class="w"> </span><span class="nv">$windowMins</span><span class="p">))</span><span class="w"> </span><span class="o">-and</span><span class="w">
        </span><span class="bp">$_</span><span class="o">.</span><span class="nf">TimeCreated</span><span class="w"> </span><span class="o">-le</span><span class="w"> </span><span class="p">(</span><span class="nv">$errorTime</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="p">(</span><span class="n">New-TimeSpan</span><span class="w"> </span><span class="nt">-Minutes</span><span class="w"> </span><span class="nv">$windowMins</span><span class="p">))</span><span class="w">
    </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">TimeCreated</span><span class="p">,</span><span class="w"> </span><span class="nx">Id</span><span class="p">,</span><span class="w"> </span><span class="nx">LevelDisplayName</span><span class="p">,</span><span class="w"> </span><span class="nx">ProviderName</span><span class="p">,</span><span class="w"> </span><span class="nx">Message</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Export-Csv</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$outputPath</span><span class="w"> </span><span class="nt">-NoTypeInformation</span><span class="w">

</span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"Exported </span><span class="si">$(</span><span class="p">(</span><span class="n">Import-Csv</span><span class="w"> </span><span class="nv">$outputPath</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Measure-Object</span><span class="p">)</span><span class="o">.</span><span class="nf">Count</span><span class="si">)</span><span class="s2"> events to </span><span class="nv">$outputPath</span><span class="s2">"</span><span class="w">
</span></code></pre></div></div>

<p>Drop the resulting CSV into your workspace and it becomes readable data. For application logs that are already plain text (anything with timestamps in a <code class="language-plaintext highlighter-rouge">.log</code> file), you don’t need to do anything — put them in the folder and they’re usable as-is.</p>

<p>One more thing about the brief. Don’t try to be thorough on the first pass. Start with the error, the timestamp, and the two dataset paths. Run an initial pass. Then update the file based on what Copilot surfaces, or add a new section when you bring in more data. I added mid-investigation instructions as a separate block at the bottom of the file. Copilot picked them up on the next question without me having to re-explain the context. The brief is a living document, not a one-shot prompt.</p>

<hr />

<p>The issue is fixed now. One version of the module, configured consistently across the environment. Users haven’t reported the problem since.</p>

<p>The part that stays with me is that there was genuinely no way to find this by reading logs the traditional way. Not because I wasn’t looking hard enough. The signal simply wasn’t visible at human reading speed, filtered through human assumptions about what an error looks like. The AI found it because it looked at everything, cross-referenced it all, and flagged something that shouldn’t have been there, even though it wasn’t causing an obvious failure.</p>

<p>That’s a different category of useful from autocomplete.</p>]]></content><author><name>Wes</name></author><category term="AI" /><category term="DevOps" /><category term="Automation" /><category term="Virtual-Desktop-Environments" /><summary type="html"><![CDATA[The issue couldn't be reproduced. The logs showed nothing. No errors, no warnings, no obvious trail. Here's how I found it anyway.]]></summary></entry></feed>