Sysadmin

Why Event Viewer Is Slow, and How I Replaced It in ~2000 Lines of Rust

The first time I had to triage a crash on a production Windows box, I opened Event Viewer, double-clicked System, and waited. Forty-five seconds later the UI was responsive again, and I spent the next ten minutes scrolling a list where each scroll tick was answered with a heartbeat of lag.

The machine had about 120,000 events in the System log. That's not a lot. The Event Log API underneath — EvtQuery, EvtNext, EvtRender — can happily stream those in under a second on any modern box. The lag is entirely the Event Viewer MMC snap-in, which is one of the last surviving relics of the early-Vista era.

I wrote a replacement in Rust called EventSleuth. It launches instantly, scrolls smoothly on million-event logs, and exports to JSON. This post is about what makes it fast, which is almost entirely about how you treat the Windows Event Log API — and a few wevtutil tricks that make the built-in tool tolerable if you can't install anything.

Windows Event Log, briefly

The modern Event Log (Windows Vista and later, sometimes called "Crimson" internally) is nothing like the old EventLog.dll NT4-style store. It's a structured, XML-based, high-throughput system with:

Channels — the logical stores you see in Event Viewer (System, Application, Security, Setup, plus hundreds of per-application Microsoft-Windows-* analytical and debug channels).

Providers — the things that write events. Each provider has a manifest that declares which event IDs it emits and what payload each one carries.

Events — XML blobs with System, EventData, and optionally UserData sections. The System section has fields every event has (EventID, Level, TimeCreated, Computer, Channel, ProviderName). EventData is provider-specific and varies by event ID.

The API to read this is entirely different from the old OpenEventLog/ReadEventLog pair you might remember. It lives in wevtapi.dll and its main entry points are:

EVT_HANDLE EvtQuery(
  EVT_HANDLE    session,       // NULL for local
  LPCWSTR       path,          // channel name or .evtx file
  LPCWSTR       query,          // XPath 1.0 subset
  DWORD         flags
);

BOOL EvtNext( EVT_HANDLE query, DWORD events_size, PEVT_HANDLE events, DWORD timeout, DWORD flags, PDWORD returned );

BOOL EvtRender( EVT_HANDLE context, EVT_HANDLE fragment, DWORD flags, // EvtRenderEventXml or EvtRenderEventValues DWORD buf_size, PVOID buffer, PDWORD buf_used, PDWORD prop_count );

Two details matter for performance:

`EvtQuery` is lazy. It returns a handle almost immediately. Nothing is read from disk yet. The actual log reads happen when you start calling EvtNext.

`EvtRender` with `EvtRenderEventXml` serialises each event to XML. XML parsing is then your problem. If you have 100,000 events you're looking at 100,000 XML parses. This is where a naive implementation dies.

Why Event Viewer is slow

Event Viewer does basically the right things, but it does them in the wrong thread. When you open a channel, the MMC snap-in calls EvtQuery, then synchronously pumps EvtNext on the UI thread until it has enumerated the whole log. Only then does it paint the list.

On a 120k-event log, that's 120k XML parses, 120k ListView row insertions, and 120k sequential disk reads, all blocking the UI thread. You get the hourglass until it's all done. Scrolling then jitters because each scroll event triggers more work on the same UI thread.

The fix is not exotic. You move the query to a worker thread, you parse events into a flat struct instead of re-parsing XML on every scroll, and you virtualise the list so the UI only renders the rows currently on screen. That's it. That's the whole trick.

EventSleuth: the architecture

The tool is a single eframe::App using egui. The threading model is straightforward:

UI thread                 Query thread
---------                 ------------
filter/scroll                      [blocked on channel recv]
    │                              │
    ▼                              │
request_query(filter) ─────────────▶ EvtQuery(channel, xpath, ...)
    │                              │
    │                              ▼
    ◀────────────── send batches ─── EvtNext(200 events)
    │ (event_batches: Vec<Event>)   │
    ▼                              ▼
append to in-memory table       EvtNext(next 200) ...

The worker thread streams events in batches of 200. Each batch goes through EvtRender once with EvtRenderEventValues (not XML) to pull the small set of fields the table actually shows — EventID, Level, TimeCreated, ProviderName, a one-line message. That gets parked in a flat Vec<Event> owned by the UI thread. The full XML is fetched lazily when you click a row for details.

Result: the UI paints after the first 200 events arrive (about 20 ms on a warm cache), and continues to stay responsive as the rest stream in. On a million-event channel, the list is navigable while the tail is still loading.

The virtual-scrolling piece is done by egui's ScrollArea::show_rows, which calls your row-rendering closure only for rows currently visible. You can have ten million entries in the backing Vec and egui will only render the ~40 rows on screen. This is the bit that Event Viewer has never done.

The EvtRender trade-off

You have two render modes:

EvtRenderEventXml — the full event as XML. Useful, expensive, ~1–2 KB per event.

EvtRenderEventValues — a binary struct of just the fields you ask for via an EvtCreateRenderContext with an XPath list. Much cheaper.

For the table view I use EvtRenderEventValues with a render context for these paths:

Event/System/EventID
Event/System/Level
Event/System/TimeCreated/@SystemTime
Event/System/Provider/@Name
Event/System/Channel
Event/System/Computer
Event/RenderingInfo/Message  -- requires FormatMessage, see below

That last one is the gotcha. The event's "message" — the human-readable string you see in Event Viewer — is not stored in the log. The log stores event IDs and parameters; the message is a template in the provider's manifest with placeholders. To render the message you call EvtFormatMessage(providerMetadata, event, 0, 0, NULL, EvtFormatMessageEvent, ...).

Loading the provider metadata is expensive, so you cache it by provider name. On a mixed log with 50 distinct providers, you open metadata 50 times and never again for the life of the process.

XPath filters: more useful than the GUI admits

The Event Viewer filter dialog is a cut-down UI over Event Log's XPath-1.0 subset. You can do a lot more in raw XPath than the dialog exposes:

<!-- All 4624 (successful logon) events where the logon type is 3 (network) -->
*[System[EventID=4624]] and *[EventData[Data[@Name='LogonType']='3']]

<!— All events from the last hour with Level <= 3 (Error or worse) —> *[System[Level <= 3 and TimeCreated[timediff(@SystemTime) <= 3600000]]]

<!— Everything mentioning a specific SID —> *[EventData[Data=‘S-1-5-21-…-1001’]]

EventSleuth exposes XPath directly alongside the normal dropdown filters, because once you know the syntax it is strictly more powerful. wevtutil takes the same query strings:

wevtutil qe Security /q:"*[System[EventID=4624]]" /f:text /c:50

That one-liner is the single most useful wevtutil trick. It streams events, honours XPath, and formats them as readable text — no MMC, no UI lag. If you can't install anything, pipe it to findstr and you have a tolerable triage workflow.

Saved .evtx files

A detail that bit me: EvtQuery accepts either a channel name or a path to an .evtx file. For the latter you set the EvtQueryFilePath flag. This means EventSleuth can open exported .evtx files from another machine with no extra code — the API is completely symmetric between live channels and files.

Every incident-response toolkit I've ever used needed this feature. Build it in from day one.

What the tool is and isn't

EventSleuth is not a full SIEM. It doesn't forward events, it doesn't correlate across machines, it doesn't do user/behaviour analysis. It's a triage tool — a better Event Viewer, nothing more ambitious.

What it does well:

Opens instantly.

Scrolls smoothly on any log size I've thrown at it.

XPath filters in a single box.

Live channels and .evtx files treated identically.

Exports filtered results to CSV or JSON.

Runs non-elevated for channels that don't require admin; prompts only when you open Security or certain Microsoft-Windows channels that do.

What it doesn't:

No graphs, no dashboards, no "timeline of last 24 hours" widgets. I want to triage, not present.

No cross-machine correlation. That's what Wazuh is for, which I've written about elsewhere.

No remote log reading over RPC. It's deliberately local-only. If you need remote Event Log access, use wevtutil's /r:hostname flag or WinRM.

When to use what

Debugging your own service on your own box: EventSleuth, or wevtutil qe.

Triaging a crash on a server someone else manages: EventSleuth against an exported .evtx. No need to install anything on the prod box — just wevtutil epl System C:tempsystem.evtx and copy the file off.

Centralised monitoring across a fleet: Event Log forwarding to a collector, or an agent like Wazuh/Sysmon feeding a SIEM. EventSleuth is not in this layer.

Ancient Windows Server 2003 / NT4 boxes: the old EventLog.dll API, which neither EventSleuth nor wevtutil speaks. You're on your own.

The broader point is that the Event Log system itself is genuinely well-designed — XPath filtering, structured events, a symmetric file/channel interface. The weak link is the MMC snap-in wrapped around it, and replacing that is roughly a weekend's work in Rust. If you find yourself doing enough Windows triage to have opinions about Event Viewer, write your own. It won't take long, and you'll understand the API better than 95% of Windows admins.

EventSleuth: github.com/swatto86/EventSleuth. Rust, egui, MIT. PRs welcome, especially for things I've explicitly left out.

← All articles