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

FileWhat it isWhereWho reads it
Diagnostic logTroubleshooting. Control flow, timing, decisions, failures. Plain text.%ProgramData%\EnpointTrust\Logs\endpoint.logA local administrator
Security audit queueEvidence about authorization decisions, bounded and delivered to Core. Not plain text.%ProgramData%\EnpointTrust\State\audit.binCore, after upload
MSI installation logWhat the installer did.Wherever msiexec /l*v was pointedWhoever 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:

FieldMeaning
tsUTC, RFC 3339, milliseconds
leveltrace debug info warn error
eventA stable event name. These are a vocabulary, not prose.
componentengine host provider tile filter helper
versionThe endpoint build
pid / tidProcess and thread
instanceA 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

LimitValueWhat happens at it
Per-file size5 MiBRotate: endpoint.2.log is deleted, .1 becomes .2, active becomes .1
Files3A fourth is never created
Record size8 KiBThe record is replaced by a well-formed one reporting size_bytes and dropped_fields
Field size512 bytesTruncated, marked ~trunc
Fields per record24The rest are dropped, dropped_fields is set
In-process queue2048 records / 2 MiBNew records are dropped and counted
Cross-process lock wait2 sThe 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:

PropertyTestWhat it injects
A discarding sink changes no outcomea_failing_sink_changes_no_authentication_outcomea sink that drops every record
A throwing sink does not escape the loggera_throwing_sink_does_not_escape_the_loggera sink that throws on every Write, at every level
A throwing sink changes no outcomea_throwing_sink_changes_no_authentication_outcomethe 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

ComponentStarts atStops at
EnpointTrustCP.dllDllGetClassObject, the first call Windows makes outside the loader lockNever explicitly. The writer thread exits by itself when idle, and DllCanUnloadNow refuses to unload while one exists.
etadmin.exewmain, before any command runsExplicitly, 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

HereAt Core
request_idThe X-Request-Id header on that request
attempt_idThe attempt Core issued
decision_idThe signed decision
grant_idThe offline authorization
policy_versionThe 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

ClaimEvidence
Levels parse; filtering works; a malformed value falls back to the defaultendpoint/tests/suite_diagnostics.cpp
Every record is a well-formed, bounded JSON linesuite_diagnostics.cpp
A hostile value cannot forge a recordsuite_diagnostics.cpp
An unallowed field name does not print its valuesuite_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 pathssuite_diagnostics.cpp
A failing sink changes no authentication outcomesuite_diagnostics.cpp
A failed deployment, an unactivated endpoint and a refused activation are still diagnosedsuite_diagnostics.cpp
The default level with nothing configured is traceendpoint/windows/tests/suite_windows_diagnostics.cpp — needs an elevated Windows process; skipped otherwise
Rotation stays inside the file and count budgetsuite_windows_diagnostics.cpp — Windows, elevated
Concurrent writers do not interleave or tear a recordsuite_windows_diagnostics.cpp — Windows, elevated, and with THREADS rather than processes; see below
An inaccessible sink drops records and survives teardownsuite_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.