Skip to content

Logging and Auditing

Rod Christiansen edited this page Sep 5, 2026 · 2 revisions

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.

Line format

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.

The structured event stream

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, message

Isolate 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'

Verbosity

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.

Retention and rotation

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.

Legacy log migration

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.

Audit action catalogue

Grep these strings. They are the contract.

Run boundaries

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

Decisions

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.

Account outcomes

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

Profile outcomes

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>.

Recycle bins

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.

Auditing what was removed

Everything deleted in one run, one line:

Select-String -Path 'C:\ProgramData\ManagedUsers\logs\manageusers.audit.log' -Pattern 'RUN_SUMMARY' | Select-Object -Last 1

Every 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 -Sum

Confirm 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

Lines that should make you stop

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

Retention advice

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

See also

ManageUsers

Home

Before you deploy

Reference

Internals

Help


This tool deletes user accounts and profiles. Run --simulate first.

Clone this wiki locally