In round 8 of the validation kit I put a fresh VM through Autopilot with an Enrollment Status Page that blocked on three Win32 apps, two for the device and one for the user. AppWorkload.log recorded the page closing at 07:11:22 UTC:
| |
The user’s blocking app was still InProgress at that point. The same log marked it Completed at 07:12:02, forty seconds later, and the check-in the line promised ran at 07:12:22, after the app finished. None of that order shows on the console, which has a progress bar and a spinner. The only record of what ran when is what the agent wrote down.
The Intune Management Extension writes a lot down. Every script launch, detection verdict, install exit code and report goes into one of four logs under C:\ProgramData\Microsoft\IntuneManagementExtension\Logs, in the CMTrace format. Most of the findings in this series were read out of them, and reading them by hand got old fast. This post covers the three IntuneScriptLab commands that read them for you: Get-IntuneAgentLog, Get-IntuneAgentTimeline and Export-IntuneAgentDiagnostic.
CMTrace is huge for diagnosing things with Intune, and I hate when admins just look at the portal logs and give up. This is where the meat and potatoes is for a device issue.
One format across four logs
Each log covers its own part of the agent:
| Log | What it holds |
|---|---|
| IntuneManagementExtension.log | Check-ins, platform script policy fetches and launches, the agent’s start |
| HealthScripts.log | Remediation schedules, queueing, launches, detection verdicts, reports |
| AppWorkload.log | Win32 app policies, applicability, detection, installs, reports, the Enrollment Status Page |
| AgentExecutor.log | The process that runs every script: its launch, exit code, output and errors |
The agent rolls a log over when it grows, so a folder that’s been running a while also has files like IntuneManagementExtension-20260924-131114.log. -Log picks which logs to read by name, rolled files included, and All reads every .log in the folder.
A CMTrace entry is one line of XML-ish text, except when it isn’t:
| |
The message can span several lines. The time carries seven fractional digits and sometimes a UTC bias suffix, and the date is M-d-yyyy. type is the level: 1 for information, 2 for a warning, 3 for an error. ConvertFrom-IslCmTraceLog matches the whole entry in one pattern with Singleline on, so a multi-line message stays one entry:
The agent keeps its current log open for writing, and a plain ReadAllText fails on a live log. The parser opens the file with shared read and write access:
The diagnostic export copies files the same way, and it needs to: it copies every log while the agent is still writing to them.
Lines with names
An entry as an object is useful. An entry that says what it records is better. Get-IntuneAgentLog checks every message against a table of 44 patterns, and a match sets the entry’s Event, its Detail and its Id. -ListEvent prints the table:
| |
| |
Every pattern is a line one of the rounds used as evidence, which is why the table is short. The agent writes far more than 44 kinds of line, and an unnamed line still comes through with an empty Event, so -Pattern can find it.
The DetectionResult line has a typo in it. The agent writes the pre-remdiation detection script compliance result, with the “e” missing, and rem\w*diation in the pattern matches it with or without the letter.
The capture groups become Detail: pre False for a pre-remediation detection that found the issue, 4 for a report’s result code, Install for an app execution. The Id is the first GUID in the message that isn’t all zeros. On a userless check-in the agent writes the device’s user id as 00000000-0000-0000-0000-000000000000, and on some lines it comes before the policy id, so taking the first GUID gave the wrong one until 0.23.0.
One AgentExecutor line carries the script’s own output. After a script ends, the agent logs write output done. output = <stdout>, error = <stderr>, both streams in one message, and ExecutorOutput puts them in Detail. When you want to know what your script printed on the device, and what it wrote to stderr, that’s the line. Most scripts print nothing, so on VM 126 I kept the output lines that carry text:
| |
That stderr belongs to a detection script from round 8, still running every hour as the signed-in user. It appends to a file that a SYSTEM script created under C:\ProgramData\IntuneScriptLab, and the user can’t write there. The exit code just above it is 0, so nothing in the exit codes alone tells you the script failed. A remediation detection that exits 0 with something on stderr still counts as no issue found, and its timeline on VM 126 ends in report 3 every hour. The same line from a Win32 detection would mean NotDetected, as the Win32 post showed.
A few lines carry no id at all. AgentExecutor’s lines name the script’s path, not its policy, and the Win32 install exit code line, [Win32App] lpExitCode 0, names nothing. Those come back with an empty Id, and you read them by time.
Asking about one policy
The filters are what make the command useful on a real device, where you’ll have tens of thousands of entries and care about a dozen. -Id keeps entries that mention a policy or app, -EventName keeps named events, and -Level, -After, -Before, -Pattern and -Last do what their names say:
| |
The report lines are shortened at ... here; on the screen each one wraps its whole JSON over a dozen lines.
-Id on its own is broader than it looks. It keeps every line that mentions the id, named or not, and on VM 126 two hours of one remediation came to about 150 entries: the script processing lines, tamper validation, the agentexecutor.exe command line twice, its argument lines. That’s the right answer when you’re hunting for something the table doesn’t name. When you want the cycle itself, filter on the event, as above.
Each hour reads the same way: the schedule inspected, the launch three milliseconds later, the detection’s verdict thirteen seconds after that, the report with result 3 (no issue found), and the next inspection at once. On the first run after a policy fetch, the launch comes five minutes after the inspection, because the scheduler queues it; on a device that’s been running a while, the launch follows the inspection straight away. The entries from all four logs are merged in time order, so leaving the event filter off puts the AgentExecutor lines between the launch and the verdict.
Times are the local times the agent wrote, which on the lab VMs wasn’t always the time I thought it was: one VM’s clock ran seven hours behind UTC.
Speed came later than correctness, in 0.28.0. The first version built an object for every entry and then filtered, and on a 15 MB agent log of 80,000 entries that took 62 seconds to read everything and 25 seconds to find one policy. The filters now run on the raw strings before an entry is built:
Reading every entry of that log takes 30 seconds now, and one policy by -Id takes 5. A rolled-over file whose last write is before -After isn’t opened at all, since everything in it is older.
One expression for the table
The other half of the speedup is the event table. Checking 44 patterns one at a time cost 44 statements a message, and most messages match none of them. The table is compiled once into a single alternation, every capture group renamed so the winning alternative can be found afterwards:
With alternation, the order of the table decides ties. Two patterns can fit the same text: AppRelationshipReport and AppReport both start with Sending status to company portal based on report, and the relationship one is longer. At a given position the regex engine tries the alternatives in order, so the relationship pattern sits earlier in the table and wins when it fits.
-EventName gets one more shortcut. Each pattern starts with some literal text, [HS] Runner: script for RemediationStart, and a message that doesn’t contain the literal can’t match. Get-LiteralPrefix reads that text off the front of each pattern, and the parser drops messages without it before any regex runs. Between that and the single expression, asking for a few events with -Last went from 90 seconds to 12 on the same log.
A policy’s story
The entries are the raw material. Most of the time the question is “what happened to this remediation”, and Get-IntuneAgentTimeline answers it. It reads the logs through Get-IntuneAgentLog, keeps the named lines with an id, and groups them, so one object tells the story of one remediation, platform script or Win32 app:
| |
Run on a quiet device, that returns hourly remediation cycles and installed apps whose only steps are AppSubgraphSkipped. This block was captured during round 8 on VM 126, while the Enrollment Status Page was running, which makes a better illustration:
| |
Win32 apps get their names from the policy list the agent logs when it fetches app policies, a JSON array with "Id" and "Name" pairs:
Remediations and platform scripts have no such list in the logs, so they show the id. Kind comes from which log the steps came from. Runs counts launches: remediation starts, script policy starts and app executions. Outcome is the last result the agent logged for that id.
For a remediation, that result is the Result code from its report line, and I had those codes wrong in the help until 0.26.0. Matching the report lines against Graph’s run states on the lab devices settled them. 3 means the detection found no issue. 4 means it found one and the remediation ran, whether or not the post-detection passed afterwards; Graph tells those apart as remediationState success or remediationFailed. 5 means the detection script itself failed. A timeline that ends in RemediationReport 4 says the remediation ran, and you need Graph or the device’s RemediationStatus to know if it fixed anything.
-Id narrows the timelines to one policy or app. It used to return every timeline whose lines mentioned the id, and a relationship report names two apps, so asking about a dependency’s parent also returned the child. It returns the timeline whose own id it is since 0.26.0.
The Enrollment Status Page, line by line
Round 8 is the best showcase, because the page’s whole run is only in the logs. The page’s own lines got names after that round: the phase, the apps it selected for each phase, each app’s registration for tracking, the tracked state changes, each phase completing, the page completing, and the check-in that follows. Asking for those events in order reads the page back:
| |
These rows are from the round itself. The agent has since rolled those lines off VM 126’s logs, and I hadn’t kept a copy, which is one argument for packing them into a zip while you still have them.
Put next to the scripts’ own launch lines, those events answered questions Learn doesn’t. The ESP troubleshooting page says the sidecar tracks only Win32 apps during the page, and the logs showed what that means for scripts. A device-assigned SYSTEM platform script launched at 07:06:05, in the device setup phase, before either blocking app was even registered at 07:06:31. The page didn’t wait for it, and it couldn’t rely on anything those apps install.
The two blocking device apps ran one at a time, in policy-list order, about fifty seconds each. No remediation ran during either phase: HealthScripts fetched both policies at 07:10:02 and the first detections ran at 07:15, three minutes after the page closed. A required app that wasn’t in the blocking list didn’t start until 20 seconds after the page closed. The EspNontrackedCheckin line explains that one: a flighting key, RunNontrackedAppsCheckinImmediatelyAfterEsp, set to True, which starts untracked apps straight away so they don’t wait for the hourly check-in.
The SYSTEM script also ran a second time, at 07:10:32, keyed to the user’s id in the user’s script batch, still as SYSTEM in session 0. A device script is part of every user’s first script check-in, so it runs twice on a fresh device. If yours isn’t idempotent, that line is where you’ll find out why.
Restarting the agent
Most of your log reading on a test device follows a change in the tenant, and the quickest way to make the device fetch is to restart the agent. Two things from the rounds affect what the logs show afterwards.
Restart-Service IntuneManagementExtension doesn’t restart it. In round 11 it reported the service running, but the stop had failed, the process kept its start time, and the next runner cycle was an hour away. Stopping the service’s process and starting the service fetched the new assignments within ten minutes. Comparing the process start time before and after is the check.
A real restart isn’t instant either. After a start, the agent logged Delaying all checkins by 36 s and then Delaying PS and Win32 app workload checkins by 147 s, so the first workload check-in came about three minutes later. After each remediation fetch, the scheduler queues a run five minutes out (RemediationQueued in the table), and restarting the agent before then throws the queued run away. Restarting twice in quick succession to hurry things along makes them slower.
A zip for a ticket
When the question goes to someone else, Export-IntuneAgentDiagnostic packs what the rounds needed into one file:
| |
The zip holds every log in the agent’s folder, copied with shared access. The registry part is text: the agent’s state under HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension (policies, Win32 app states, script reports), the enrollment’s FirstSync values, the Enrollment Status Page tracking and the Autopilot diagnostics. A device section records the Windows build, PowerShell version, agent version and service state, next to the output of dsregcmd /status. Two CSVs carry the parsed logs, every named line in one and a row per timeline in the other, and Manifest.txt says what’s in the package.
The registry walk caps each value at 400 characters and writes a byte array as its length, so a long value such as a script report’s JSON shows its start and a binary value shows its size:
The zip itself is built entry by entry. .NET Framework’s ZipFile.CreateFromDirectory writes backslashes into entry names, and the zip format expects forward slashes, so Compress-Staging writes each entry itself with forward slashes on every host.
Treat the zip as the support data it is. The logs and the registry text carry user ids, user names and policy ids. -SkipRegistry leaves the registry out, and -LogPath builds the package from a log folder copied off another device, in which case the Device\ section still describes the machine you ran it on.
Where the logs stop
The logs tell you what the agent did on this device. A few things they don’t hold.
The portal’s verdict isn’t in them. A remediation’s report line carries a result code, and the device’s RemediationStatus and Graph’s run states can still disagree with what you’d infer from it, as the result 4 case shows. Getting the tenant’s view of the same run is the job of the tenant-side commands, which get their own post.
Lines without an id stay outside the timelines. AgentExecutor’s launch, exit code and output lines are the closest thing to a script’s own voice in the logs, and they belong to no timeline, because they name a file path. Get-IntuneAgentLog -Log AgentExecutor shows them by time, next to the timeline whose launch they follow.
The event table is the lines the rounds needed. An agent update can reword a line, and a reworded line drops out of its event until the table catches up. -Pattern still finds it, and -ListEvent shows exactly what each event expects.
The commands read logs on any machine, so a copied folder works with -Path from a laptop with no agent on it. The Enrollment Status Page rows, with the experiment behind each, are in the “The Enrollment Status Page” section of Validation/Findings.md. The code in this post is from IntuneScriptLab 0.31.0, and the runs on VM 126 used 0.31.0 as SYSTEM.
