Local diagnostic logging
An administrator with a machine that will not sign anybody in needs to be able to read
what the endpoint actually did — including when Core is unreachable, enrollment never
completed, or the deployment configuration is invalid. That is what this file documents.
The default level is trace. In Debug and in Release, on a fresh MSI installation,
with nothing configured. That is a deliberate first-release decision for an endpoint that
is still stabilising, not an opt-in debug mode, and the MSI preserves it.
---
1. Three different files, and they are not interchangeable
| File | What it is | Where | Who reads it |
|---|---|---|---|
| Diagnostic log | Troubleshooting. Control flow, timing, decisions, failures. Plain text. | %ProgramData%\EnpointTrust\Logs\endpoint.log | A local administrator |
| Security audit queue | Evidence about authorization decisions, bounded and delivered to Core. Not plain text. | %ProgramData%\EnpointTrust\State\audit.bin | Core, after upload |
| MSI installation log | What the installer did. | Wherever msiexec /l*v was pointed | Whoever ran the installer |
They are separate on purpose. The audit queue is a record somebody may later rely on; the
diagnostic log is a stream of implementation detail. Putting the second inside the first
would make the first unreadable and the second undeliverable, and the diagnostic log is
explicitly not sent to Core.
---
2. Location, format and permissions
Directory: %ProgramData%\EnpointTrust\Logs — normally C:\ProgramData\EnpointTrust\Logs.
The path is fixed. It is never taken from configuration, a command line, or a server
response: a log path somebody else chooses is a write primitive.
Files: endpoint.log is active; endpoint.1.log and endpoint.2.log are the rotated
ones. Three files, each at most 5 MiB, so about 15 MiB total.
Permissions: SYSTEM and BUILTIN\Administrators, full control, and inheritance from
ProgramData is blocked. Without that block the permissive ACEs ProgramData grants
would apply here and the files would be readable by any local account. The installer
establishes this before the provider is registered, and etadmin repair re-applies it.
Format: UTF-8 JSON Lines. One self-contained JSON object per line, readable with
anything:
{"ts":"2030-01-01T09:14:02.317Z","level":"debug","event":"api_request_completed","component":"engine","version":"0.1.0","pid":1884,"tid":4120,"instance":"9f2c41a7b8e05d13","path":"/v1/auth/validate","request_id":"b8bd8408-722a-4dfc-9fa8-942022787276","elapsed_ms":212,"status":"ok","http_status":200,"transport":"none"}
Every record carries:
| Field | Meaning |
|---|---|
ts | UTC, RFC 3339, milliseconds |
level | trace debug info warn error |
event | A stable event name. These are a vocabulary, not prose. |
component | engine host provider tile filter helper |
version | The endpoint build |
pid / tid | Process and thread |
instance | A per-process id. PID reuse is common on a machine that reboots into a logon screen repeatedly, and without this two LogonUI runs are indistinguishable. |
Correlation fields appear when they exist: tenant_id, user_id, association_id,
attempt_id, challenge_id, decision_id, grant_id, request_id,
credential_generation, windows_session_id, channel, operation, policy_version.
Nothing is invented to fill one in, and no directory or network lookup is ever performed
to enrich a record.
Outcomes appear as elapsed_ms, status, reason, http_status, transport,
ntstatus, substatus, outcome, basis, bypass, coverage.
Reading it
# The last 40 records, as objects
Get-Content C:\ProgramData\EnpointTrust\Logs\endpoint.log -Tail 40 |
ForEach-Object { $_ | ConvertFrom-Json } | Format-Table ts, level, component, event, reason
# One sign-in attempt, start to finish
Select-String -Path C:\ProgramData\EnpointTrust\Logs\*.log `
-Pattern '77777777-7777-4777-8777-777777777777'
---
3. What is never written
Not at trace, not at any level, and there is no switch that relaxes it:
passwords · OTPs · the contents or lengths of the password and code fields ·
keystrokes · enrollment credentials · device or bearer tokens · Authorization headers ·
full phone numbers · the masked destination Core supplies · serialized Windows
credentials · raw signed licenses, decisions, policies or grants · raw HTTP request or
response bodies · memory dumps.
Nor their hashes or encodings. A hash of a six-digit OTP is an OTP.
The redaction is structural, not a review convention. A record is built from an
allowlist of field names (endpoint/core/src/log.cpp), and the formatter replaces any
field it does not recognise with an unallowed_field marker rather than printing its
value. A new call site that invents a field name gets a visible marker in the log and a
failing test, not a leak. Filtering happens on the calling thread before anything
enters the queue, and a queued record owns copies of safe strings — it never holds a
pointer into a credential buffer.
Values that did not originate in this build — a Core reason code, a problem code, a
configuration field name — go through SanitizeForLog: printable ASCII only, bounded,
truncation marked. So a hostile reason code cannot forge a second record, break a line, or
move a terminal's cursor.
Raw AD usernames and UPNs are avoided in favour of the opaque ids Core supplies. An
attempt is followable by attempt_id and request_id without naming the person.
---
4. Changing the level
Machine-local, administrator-only, and it works on a machine with no license, no
activation, no Core connectivity and no user profile:
HKEY_LOCAL_MACHINE\SOFTWARE\EnpointTrust\Diagnostics
LogLevel REG_SZ trace | debug | info | warn | error | off
# Quieten it down
reg add HKLM\SOFTWARE\EnpointTrust\Diagnostics /v LogLevel /t REG_SZ /d info /f /reg:64
# Back to the default
reg delete HKLM\SOFTWARE\EnpointTrust\Diagnostics /v LogLevel /f /reg:64
The 64-bit registry view is used consistently, by both the x64 provider and the x64
helper, so a 32-bit tool writing the same path does not end up configuring something else.
Absent means trace. A value that is not a level also means trace, with a warning
record naming the value. off is the only way to get silence, and it has to be asked for.
A running logger keeps its level until its process exits. The value is read once when a
logger instance starts. There is no polling process and no watcher. In practice: a change
takes effect on the helper immediately (it is a new process each time) and on the logon
screen when LogonUI next restarts — signing out, locking and unlocking, or a reboot.
etadmin status prints the configured and effective level, the directory, and any sink
problem this process has seen. It does not and cannot report the live health of the
LogonUI instance on the secure desktop; a guess there would be worse than the limit.
---
5. Limits, stated rather than discovered
| Limit | Value | What happens at it |
|---|---|---|
| Per-file size | 5 MiB | Rotate: endpoint.2.log is deleted, .1 becomes .2, active becomes .1 |
| Files | 3 | A fourth is never created |
| Record size | 8 KiB | The record is replaced by a well-formed one reporting size_bytes and dropped_fields |
| Field size | 512 bytes | Truncated, marked ~trunc |
| Fields per record | 24 | The rest are dropped, dropped_fields is set |
| In-process queue | 2048 records / 2 MiB | New records are dropped and counted |
| Cross-process lock wait | 2 s | The batch is dropped and counted |
Dropped records are reported on a later record as dropped, not as a record of their
own: a loss record that itself overflows the queue is a loop, not a diagnostic.
What is NOT promised. Not lossless delivery, and not a durable flush per record. The
writer does not call FlushFileBuffers on the logon path, so an abrupt process exit — a
power cut, a TerminateProcess — may lose a bounded buffered tail. Logging a sign-in is
not worth delaying one.
What a logger failure never does. A diagnostic failure changes no authentication
decision, and no exception from the logging path reaches a COM boundary or a thread entry
point. Both are now enforced boundaries rather than conventions, and each says so where it
is: Logger::Emit in endpoint/core/include/et/log.h, the writer thread and its startup
in endpoint/windows/platform/diagnostic_log.cpp.
This paragraph previously claimed the exception property outright and cited
a_failing_sink_changes_no_authentication_outcome as the proof. That test uses a sink
that discards, not one that throws, so it never established it — and a review's
compiled probe showed an exception escaping Logger::Info. The boundary was added in
response; the claim is now true, and these are the tests that hold it:
| Property | Test | What it injects |
|---|---|---|
| A discarding sink changes no outcome | a_failing_sink_changes_no_authentication_outcome | a sink that drops every record |
| A throwing sink does not escape the logger | a_throwing_sink_does_not_escape_the_logger | a sink that throws on every Write, at every level |
| A throwing sink changes no outcome | a_throwing_sink_changes_no_authentication_outcome | the same sink, across a whole sign-in |
All three are ordinary-process portable tests, in endpoint/tests/suite_diagnostics.cpp.
et::ContainedDiagnosticFailures() counts contained failures process-wide, so a silent
containment is still a visible fact.
What runs on the caller, and what runs on the writer. On the calling thread:
the level check, building the field vector, formatting the record, and appending it to the
queue under the queue mutex. On the background writer: the file open, the append, rotation,
and the cross-process lock wait. So the caller does allocate and format — it is not an
unconditional non-blocking path, and the claim here is bounded work and a contained
failure, not zero work.
What is still NOT demonstrated. The Windows sink's own failure modes — a denied
directory, a full disk, a rotation failure, a cross-process writer contending for the lock
— are covered only by same-process tests in endpoint/windows/tests, which until recently
did not execute at all (they were silently dropped from ctest; see the implementation
report). There is no multi-process writer test and no observation of any of this inside
LogonUI. Treat the queue, rotation and lock rows in the table above as the configured
intent, verified in one process, and unverified on a logon screen.
What cannot be logged at all. A DLL that fails to load cannot write its own
diagnostics. If the provider is not loading, the diagnostic log will be silent about it,
and the MSI's own verbose log plus the installer's runtime prerequisite check are the
separate controls that cover that case. This is why they remain separate controls.
---
6. Where logging starts, and where it deliberately does not
| Component | Starts at | Stops at |
|---|---|---|
EnpointTrustCP.dll | DllGetClassObject, the first call Windows makes outside the loader lock | Never explicitly. The writer thread exits by itself when idle, and DllCanUnloadNow refuses to unload while one exists. |
etadmin.exe | wmain, before any command runs | Explicitly, after the command, with a bounded drain |
DllMain does nothing but record the module handle and disable thread library calls. No
logging initialisation, no filesystem work, no thread creation, no waits, and no flush or
shutdown on detach — all of that runs under the loader lock, where it can deadlock the
process that is loading the DLL, and that process draws the logon screen. The same rule
rules out static constructors and destructors.
The writer thread holds a module reference for its whole life, so no thread can execute
code from an unloaded module. It exits after a short idle period rather than being pinned,
and the next record starts a new one.
---
7. Collecting and keeping
Manual collection is what this release supports. There is no log viewer, no remote
shipping and no observability framework.
# From an elevated prompt
$out = "$env:USERPROFILE\Desktop\enpoint-logs"
New-Item -ItemType Directory -Force -Path $out | Out-Null
Copy-Item C:\ProgramData\EnpointTrust\Logs\*.log $out
& 'C:\Program Files\Enpoint Trust\etadmin.exe' status > "$out\status.txt"
Compress-Archive -Path $out -DestinationPath "$out.zip"
Copying the active file while the endpoint is writing is safe: the writer appends under a
cross-process lock, and a reader opening with share-read sees whole lines.
Retention. The log files are retained through upgrade and through uninstall. The
records explaining why the product was removed are usually wanted after it is, and 15 MiB
is a small price. etadmin purge --yes does not touch them either: purging
authentication state and deleting troubleshooting records are different decisions, and
conflating them would destroy the evidence for the problem that prompted the purge.
Deliberate cleanup is an administrator's own step:
Remove-Item C:\ProgramData\EnpointTrust\Logs\*.log
---
8. Correlating with Core
| Here | At Core |
|---|---|
request_id | The X-Request-Id header on that request |
attempt_id | The attempt Core issued |
decision_id | The signed decision |
grant_id | The offline authorization |
policy_version | The device policy revision in force |
api_request_started and api_request_completed bracket every exchange with the
operation, the request id, the elapsed milliseconds, the HTTP status and the canonical
transport error. Neither record contains a header or a body.
Failures that happen before any of this exists, and therefore correlate with nothing at
Core: deployment_trust_rejected, license_rejected, setup_refused,
registration_inconsistent, state_quarantined. Those are entirely local and are exactly
the cases where this file is the only source of truth.
---
9. What has been tested, and what has not
| Claim | Evidence |
|---|---|
| Levels parse; filtering works; a malformed value falls back to the default | endpoint/tests/suite_diagnostics.cpp |
| Every record is a well-formed, bounded JSON line | suite_diagnostics.cpp |
| A hostile value cannot forge a record | suite_diagnostics.cpp |
| An unallowed field name does not print its value | suite_diagnostics.cpp |
| No sentinel password, OTP, token, enrollment credential, phone mask, serialized document or AD handle reaches a record, on the success path or the error paths | suite_diagnostics.cpp |
| A failing sink changes no authentication outcome | suite_diagnostics.cpp |
| A failed deployment, an unactivated endpoint and a refused activation are still diagnosed | suite_diagnostics.cpp |
The default level with nothing configured is trace | endpoint/windows/tests/suite_windows_diagnostics.cpp — needs an elevated Windows process; skipped otherwise |
| Rotation stays inside the file and count budget | suite_windows_diagnostics.cpp — Windows, elevated |
| Concurrent writers do not interleave or tear a record | suite_windows_diagnostics.cpp — Windows, elevated, and with THREADS rather than processes; see below |
| An inaccessible sink drops records and survives teardown | suite_windows_diagnostics.cpp |
NOT established by any automated test. Two real LogonUI sessions plus the helper
writing concurrently; the DACL as applied by the real installer; a readable correlated log
for a successful console or RDP sign-in, a wrong OTP, a correct OTP with a wrong Windows
password, an API or TLS failure, a cancellation, a tile switch, or a recovery or grace
path. Those are lab observations, they are listed in
windows-acceptance.md, and none of them has been made.
This feature is not evidence that any other defect is fixed. It makes behaviour
visible. It does not make a broken authentication path correct, and a log record is not an
acceptance result.