Repository navigation
Logging and Auditing
Everything lives under C:\ProgramData\ManagedUsers\logs. Operational output
goes into a day directory named for the local date the run started; the audit
log stays at the root, outside the day directories.
C:\ProgramData\ManagedUsers\logs\
├── 2026-09-01\
│ ├── manageusers.log
│ └── events.jsonl
├── 2026-09-02\
│ ├── manageusers.log
│ └── events.jsonl
├── manageusers.audit.log
└── manageusers.audit.log.1
| Path | Contents |
|---|---|
<yyyy-MM-dd>\manageusers.log |
Everything that day — every evaluation, every skip, every warning, plus a mirror of every audit entry |
<yyyy-MM-dd>\events.jsonl |
The same entries, structured, one JSON object per line |
manageusers.audit.log |
Only deletion decisions and outcomes. This is the file you keep. |
The day directory is the tool's unit of session. ManageUsers runs far too often to justify a directory per invocation, so every run on a given local date appends to that date's pair of files; each structured record carries the process id and a per-invocation id so one run can still be picked out of a shared day. This is the layout the sibling managed tools share, so a day directory can hold more than one tool's output.
Every audit entry is written to both the audit log and the day's operational
log (and therefore also to events.jsonl). The audit log sits at the root
because it must outlive the day directories: "which accounts were deleted, when,
and why" has to stay answerable long after that day's operational log has been
swept.
The human-readable log line is unchanged:
[yyyy-MM-dd HH:mm:ss] LEVEL message
Local machine time, no timezone offset, level left-aligned to five characters.
[2026-09-02 03:00:04] INFO ManageUsers starting
[2026-09-02 03:00:05] WARN Could not resolve SID for olduser — skipping
[2026-09-02 03:00:09] ERROR Verification failed — user student2 may not be fully deleted
Levels are INFO, WARN and ERROR. Audit entries are always written at
INFO, with the message shaped as ACTION | key=value key=value.
events.jsonl carries one JSON object per line — no wrapping array, no pretty
printing — written for every line that reaches the operational log. All seven
fields are always present, all seven values are strings, and the field order is
fixed:
| Field | Value |
|---|---|
timestamp |
Local time as yyyy-MM-ddTHH:mm:ss.fffzzz, including the UTC offset |
level |
INFO, WARN or ERROR — the same token as the text line |
event_type |
error when the level is ERROR, otherwise message
|
tool |
Always manageusers, so a shared day directory stays attributable |
pid |
The process id of the run that wrote the record |
invocation_id |
A GUID generated once per process; groups every record from one run |
message |
The message text, without the [timestamp] LEVEL prefix |
There is no error or stack-trace field: an unhandled exception is formatted
into message like any other text, at ERROR.
Three consecutive records from one run, exactly as emitted:
{"timestamp":"2026-09-02T03:00:04.117-07:00","level":"INFO","event_type":"message","tool":"manageusers","pid":"7284","invocation_id":"9f2c1a54-6b0e-4d3a-8c11-71d0f9a5e2b3","message":"RUN_START | mode=live force=False usage=Shared policy=Policy(Duration=28d, Strategy=LoginAndCreation, ForceTermDeletion=False) endOfTerm=False"}
{"timestamp":"2026-09-02T03:00:05.402-07:00","level":"WARN","event_type":"message","tool":"manageusers","pid":"7284","invocation_id":"9f2c1a54-6b0e-4d3a-8c11-71d0f9a5e2b3","message":"Could not resolve SID for olduser — skipping"}
{"timestamp":"2026-09-02T03:00:09.980-07:00","level":"ERROR","event_type":"error","tool":"manageusers","pid":"7284","invocation_id":"9f2c1a54-6b0e-4d3a-8c11-71d0f9a5e2b3","message":"Verification failed — user student2 may not be fully deleted"}Records are serialized by hand rather than through a JSON library, so the escape
set is deliberately narrow: ", \, newline, carriage return, tab, and any
other control character as \uXXXX. Non-ASCII text passes through unescaped.
Windows paths therefore appear with doubled backslashes:
"message":"RECYCLE_BIN_REMOVED | owner=student2 sid=S-1-5-21-… path=C:\\$Recycle.Bin\\S-1-5-21-… bytes=41231872".
Writing a structured record is best-effort. If it fails, the run continues and
the text log still has the entry — the stream is a convenience, never a failure
mode. Treat manageusers.log as authoritative when the two disagree.
Read a day's errors back out with ConvertFrom-Json:
Get-Content 'C:\ProgramData\ManagedUsers\logs\2026-09-02\events.jsonl' |
ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object level -eq 'ERROR' |
Select-Object timestamp, invocation_id, messageIsolate a single run once you have an invocation_id:
Get-Content 'C:\ProgramData\ManagedUsers\logs\2026-09-02\events.jsonl' |
ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object invocation_id -eq '9f2c1a54-6b0e-4d3a-8c11-71d0f9a5e2b3'There is no verbosity switch — no --verbose, no --debug, no log-level
setting in Config.yaml or the environment. The tool logs every account it
evaluates and its reason for keeping or deleting it, at all times, and no level
below INFO is ever emitted. The only lever on what gets logged is
--simulate, which adds the _SIMULATED audit actions in place
of the destructive ones.
Every log line is also mirrored to stdout, coloured by level, so running the binary interactively gives you the full operational log live.
Two independent mechanisms, applied at process start.
Age, for day directories. Every run first sweeps the logs root. Any
subdirectory whose name parses as a yyyy-MM-dd date older than 30 days
before today is deleted whole, events.jsonl and all. Directories whose names
are not dates are left alone.
The same age rule is then applied to loose files sitting at the logs root — the
residue of the flat layout this replaced, including a log migrated up from an
even older location. Anything at the root whose name begins with
manageusers.audit.log is exempt, which covers the audit log and every rotated
generation of it.
The sweep is entirely best-effort: a file held open or an access denial is stepped over, and retention never stops a run.
Size, for the two .log files. Rotation is unchanged and applies to the
day's manageusers.log and to manageusers.audit.log:
| Property | Value |
|---|---|
| Trigger | File size above 10 MB, checked once at process start |
| Generations kept | 5 (manageusers.log.1 … manageusers.log.5) |
| Scheme |
.N shifts to .N+1, oldest dropped, current file becomes .1
|
| On rotation failure | Logging continues by appending to the oversized file |
events.jsonl is not size-rotated. It only ever grows until its day
directory ages out, so a device in a warning loop can leave a large file behind
for up to 30 days.
The net effect: operational history is capped at 30 days, while audit history is capped only by size. A device that deletes a handful of accounts a week will hold years of audit history; a device thrashing with warnings will push it out. If audit history matters to you, ship the audit log off the device.
Earlier releases wrote to C:\ProgramData\Management\Logs\ManageUsers.log and
ManageUsers.audit.log. On the first run of a current build, if the new file
does not exist and the old one does, the old file and its rotated generations are
moved across:
| From | To |
|---|---|
Management\Logs\ManageUsers.log |
ManagedUsers\logs\manageusers.log — the logs root, not a day directory |
Management\Logs\ManageUsers.audit.log |
ManagedUsers\logs\manageusers.audit.log |
The migrated operational log deliberately lands flat at the root, where it is readable for a while and then ages out with the other loose files under the same 30-day rule. It is never merged into a day directory. The migration is best-effort; a failure leaves the old files alone and starts fresh at the new path.
Grep these strings. They are the contract.
| Action | Detail fields |
|---|---|
RUN_START |
mode= simulate|live, force=, usage=, policy= the resolved policy, endOfTerm=
|
RUN_SUMMARY |
mode=, removed= count, items=[…] the names removed this run |
| Action | Meaning |
|---|---|
DELETE_DECISION |
An account or profile was selected for deletion. The reason= field states which strategy fired and the observed ages, e.g. created 61d ago, last login 44d ago (threshold 30d, LoginAndCreation). Written in simulate mode too. |
There is no matching KEEP_DECISION audit action. Accounts that are kept are
logged only in the operational log, as CreationOnly: …— keep or
LoginAndCreation: … — keep.
| Action | Meaning |
|---|---|
USER_DELETE_SIMULATED |
Dry run: this account would have been deleted |
USER_DELETE_DEFERRED |
An Active session exists; queued in Sessions.yaml and retried next run |
USER_DELETED |
Account and profile removed and verified. Includes sid= and profile=
|
USER_DELETE_FAILED |
reason=account removal failed (the net user /delete step) or reason=verification failed, account or profile remnants remain
|
ORPHAN_USER_REMOVED |
An account with no profile was deleted by the orphan pass. Not age-gated, and not suppressed by --simulate.
|
ORPHAN_USER_REMOVE_FAILED |
The same, unsuccessful |
| Action | Meaning |
|---|---|
STALE_PROFILE_REMOVE_SIMULATED |
Dry run: this profile folder would have been removed |
STALE_PROFILE_SKIPPED |
reason=hive loaded, profile in use — retried next run |
STALE_PROFILE_REMOVED |
Folder and ProfileList entry removed |
STALE_PROFILE_REMOVE_FAILED |
reason=profile directory could not be fully removed — expect a remnant folder |
Corrupt-profile remediation reuses the STALE_PROFILE_* actions; the corruption
itself is recorded in the operational log as a WARN line reading
Corrupt profile state: <name> (SID: …) — <reason>.
| Action | Meaning |
|---|---|
RECYCLE_BIN_REMOVE_SIMULATED |
Dry run, includes bytes=
|
RECYCLE_BIN_REMOVED |
Contents of a deleted user's bin cleared, owner=, sid=, path=, bytes=
|
RECYCLE_BIN_REMOVE_FAILED |
With reason=
|
ORPHANED_RECYCLE_BIN_REMOVE_SIMULATED / _REMOVED / _REMOVE_FAILED
|
Same, for the end-of-run sweep of bins whose SID has no profile. owner= is often unknown — that is the point, nothing is left to attribute the files to. |
The bytes= field is measured before deletion and is useful for reporting
reclaimed space.
Everything deleted in one run, one line:
Select-String -Path 'C:\ProgramData\ManagedUsers\logs\manageusers.audit.log' -Pattern 'RUN_SUMMARY' | Select-Object -Last 1Every account deletion with its reason, across all retained generations:
Get-ChildItem 'C:\ProgramData\ManagedUsers\logs\manageusers.audit.log*' |
Select-String -Pattern 'USER_DELETED|ORPHAN_USER_REMOVED|DELETE_DECISION'Every failure worth chasing:
Select-String -Path 'C:\ProgramData\ManagedUsers\logs\manageusers.audit.log' -Pattern '_FAILED|_SKIPPED|_DEFERRED'Space reclaimed by recycle-bin clearing:
Select-String -Path 'C:\ProgramData\ManagedUsers\logs\manageusers.audit.log' -Pattern 'RECYCLE_BIN_REMOVED' |
ForEach-Object { [int64]([regex]::Match($_.Line, 'bytes=(\d+)').Groups[1].Value) } |
Measure-Object -SumConfirm the guards were in force on the last run:
Select-String -Path "C:\ProgramData\ManagedUsers\logs\$(Get-Date -Format yyyy-MM-dd)\manageusers.log" -Pattern 'Exclusions loaded|Administrator protection|Console user detected'Fleet-wide collection, adjusted for however you reach your devices:
Invoke-Command -ComputerName (Get-Content .\devices.txt) -ScriptBlock {
Select-String -Path 'C:\ProgramData\ManagedUsers\logs\manageusers.audit.log' -Pattern 'RUN_SUMMARY' |
Select-Object -Last 1
} | Select-Object PSComputerName, Line| Log line | Why it matters |
|---|---|
Config file not found: … — using built-in defaults |
The device is running a live 28-day policy you did not write |
Failed to parse Config.yaml: … — using built-in defaults |
Your exclusion list has been discarded for this run |
Config.yaml has no policies defined — using built-in defaults |
Exclusions survive, but the policy is the built-in default |
Administrator enumeration returned no members — admin accounts may be left unprotected this run |
The admin guard did not apply |
NetLocalGroupGetMembers(…) failed with code N — administrator protection may be incomplete |
Same, partial |
ProfileList could not be read; skipping orphaned recycle bin sweep |
Registry access problem; the sweep did nothing (this is the safe direction) |
Could not resolve SID for <name> — skipping |
An account was silently excluded from evaluation |
No SID for <name>; recycle bin could not be located and may remain |
Deleted profile, orphaned bin left behind until a later sweep |
Orphaned user (no profile): <name> |
Pass 2 has a target — verify it is not a service account |
Inventory file not found |
The device fell through to default_policy
|
Using DateTime.Now — policy evaluation may be inaccurate |
A profile folder's timestamps were unreadable |
The audit log is the only record that an account existed. Copy it somewhere
central on a schedule; the device's own copy is size-capped and a chatty failure
loop can push real history out of the retained generations. A daily collection of
the last RUN_SUMMARY per device is enough for reporting; a full copy of
manageusers.audit.log* is what you want when someone asks where their files
went.
The operational side is a harder deadline: day directories are deleted after 30
days, so anything you want to correlate later — the events.jsonl records behind
a deletion, the config warnings from a run six weeks ago — has to be collected
inside that window. Shipping events.jsonl is the cheaper option if you have
somewhere to put it, since it needs no line parsing:
$day = Get-Date -Format yyyy-MM-dd
Copy-Item "C:\ProgramData\ManagedUsers\logs\$day" `
-Destination "\\fileserver\share\manageusers\$env:COMPUTERNAME\$day" -Recurse -Force- Troubleshooting — what to do about the lines above
-
Selection Logic — how a
DELETE_DECISIONis reached
ManageUsers — local account and profile retention for shared Windows devices · source repository · see the repository for license terms
Before you deploy
Reference
Internals
Help
This tool deletes user
accounts and profiles.
Run --simulate first.