Windows Event Log Subscriptions: Queries, Bookmarks, and Restart-Safe Consumers
Build a durable Windows Event Log consumer with bounded XPath queries, push or pull delivery, persisted bookmarks, stale-result handling, and idempotent processing.
The Windows Event Log API lets an application query or subscribe to records in local or remote event channels and saved log files. A production consumer needs more than an EvtSubscribe call: it needs a bounded query, a deliberate push-versus-pull model, safe event-handle ownership, and a cursor that survives restart. Bookmarks provide a position in a channel or log; they do not make processing exactly-once, prevent retention from removing records, or turn the event stream into an immutable audit archive.
A channel query is a filter, not a database transaction
Choose the narrowest channel and query that cover the consumer’s purpose. Windows Event Log accepts XPath expressions over event XML, but implements only a subset of XPath 1.0. Simple selectors can filter by event ID, level, provider, or time; multi-channel selection or a compound query beyond the supported expression limits requires structured query XML. Validate the query against the actual provider schema and test it against records from every supported Windows version. Providers can evolve event templates, and a query that assumes a field always exists can silently stop matching after an update.
The result set is not an atomic snapshot frozen at subscription time. Matching events written while results are being enumerated may also appear. Event order is preserved for events written by the same thread, but events from different threads on different processors may be observed out of order. Consumers should use the event’s timestamp, provider identity, record metadata, and application-level correlation identifiers instead of inferring a global causal order from callback arrival time.
Push versus pull delivery
With a push subscription, EvtSubscribe calls an application callback when matching events arrive. The callback should do bounded work: validate the action, render or copy the event payload and the corresponding bookmark XML into application-owned memory, enqueue those owned values, and return promptly. The callback’s event handle is valid only until the callback returns; do not retain it or call EvtClose on it, because the Event Log service closes it afterward. Slow database writes or network uploads inside the callback can stall delivery and block other event notifications. Queue work to a bounded worker and define what happens when that queue is full.
With a pull subscription, the application calls EvtNext to retrieve batches. This gives the consumer direct back-pressure control and can simplify ownership, but it must handle timeouts, empty batches, shutdown, and query errors explicitly. A pull loop should request a bounded batch size, process every returned handle, and close each event handle after rendering or copying its data. A push callback receives an event handle that is valid only during that callback; render or copy the needed data before returning, and let the service close the handle.
Pick one model based on throughput and lifecycle requirements. Neither is inherently durable if the event channel wraps, is cleared, loses records, or the consumer is offline longer than the retention window. Remote subscriptions add authorization, network, and server load considerations; they are not a replacement for a controlled collection architecture such as Windows Event Forwarding when a fleet-wide pipeline is required.
Bookmarks and the processing boundary
A bookmark identifies an event position. Create an EVT_HANDLE bookmark with EvtCreateBookmark, update it with EvtUpdateBookmark while the event handle is valid, and render it as XML by calling EvtRender with the EvtRenderBookmark flag. This produces a copy that the application can queue or persist. In a push callback, capture the XML before returning, but persist it only after the associated downstream work reaches its durable boundary. On restart, reconstruct it and use EvtSubscribeStartAfterBookmark when resuming. Store the bookmark with the identity of the channel, query, and consumer version that produced it. If the query semantics change, an old cursor may no longer mean what the new consumer expects.
The crash window is important. If the application persists a bookmark before the downstream side effect commits, a crash can permanently skip work. If it commits the work first and crashes before saving the bookmark, it can process the same event again. Unless the event store and downstream system support one shared atomic transaction, design for at-least-once delivery and make the downstream operation idempotent using a stable event identity or deduplication key. Never promise exactly once just because a bookmark exists.
#include <windows.h>
#include <winevt.h>
#include <cstddef>
#include <vector>
DWORD RenderBookmarkXml(EVT_HANDLE bookmark, std::vector<wchar_t>& xml)
{
DWORD bytesUsed = 0;
DWORD propertyCount = 0;
if (EvtRender(nullptr, bookmark, EvtRenderBookmark, 0, nullptr,
&bytesUsed, &propertyCount)) {
return ERROR_INVALID_DATA;
}
const DWORD sizingError = GetLastError();
if (sizingError != ERROR_INSUFFICIENT_BUFFER || bytesUsed < sizeof(wchar_t)) {
return sizingError == ERROR_SUCCESS ? ERROR_GEN_FAILURE : sizingError;
}
const std::size_t wcharCount =
(bytesUsed + sizeof(wchar_t) - 1) / sizeof(wchar_t);
xml.resize(wcharCount);
const DWORD bufferBytes = static_cast<DWORD>(xml.size() * sizeof(wchar_t));
if (!EvtRender(nullptr, bookmark, EvtRenderBookmark, bufferBytes, xml.data(),
&bytesUsed, &propertyCount)) {
return GetLastError();
}
return ERROR_SUCCESS;
}
DWORD CaptureEventBookmark(EVT_HANDLE bookmark, EVT_HANDLE event,
std::vector<wchar_t>& bookmarkXml)
{
if (bookmark == nullptr || event == nullptr) {
return ERROR_INVALID_PARAMETER;
}
if (!EvtUpdateBookmark(bookmark, event)) {
return GetLastError();
}
return RenderBookmarkXml(bookmark, bookmarkXml);
}
Call CaptureEventBookmark inside the callback after the event payload has also been rendered or copied. Serialize access to a shared bookmark through the update-and-render sequence. Queue the owned payload and XML together, and persist that XML only after the corresponding downstream operation commits; the helper itself does not make the cursor durable. If rendering or updating fails, report the failure and do not advance the persisted cursor.
Gaps, stale queries, and channel retention
Event channels have configured retention and maximum sizes. A channel can be cleared or records can roll over while a consumer is offline. A strict subscription can report ERROR_EVT_QUERY_RESULT_STALE when records matching the query are missing. Treat that as a gap requiring explicit policy: alert and resume from the earliest available record, run a state reconciliation, or stop and require operator review. A missing interval cannot be reconstructed by repeatedly retrying the same bookmark.
Bookmarks are not a tamper-resistant audit archive. Persist them with appropriate ACLs and protect event payloads according to their sensitivity. Local administrators can modify logs, channels may have auditing policy limits, and source events may be absent because the relevant provider was disabled or its events were dropped. For compliance, define separate collection, retention, integrity, and access-control requirements.
Render structured data safely
EvtRender can return event XML or selected properties using an EVT_RENDER_CONTEXT. Use a size-probe call to learn the required buffer size, allocate within a defensible upper bound, and handle the documented buffer-too-small result before rendering. Treat provider-supplied fields as data, not markup to concatenate into HTML or SQL. Store provider name, event ID, channel, record ID, time, and typed payload fields separately when possible. Localized message formatting is a presentation layer and can fail if the provider’s message resources are unavailable; retain the structured XML or typed values needed for diagnosis.
Every Event Log handle owned by the application needs a close point. Close subscriptions, pull-result event handles, bookmarks, query results, and render contexts with EvtClose when no longer in use; the push-callback event handle is service-owned and must not be closed by the callback. During shutdown, mark the consumer as stopping and close the subscription to cancel delivery, then use explicit in-flight-callback and worker-drain synchronization before freeing shared callback state. Persist only the last bookmark whose associated work is durable. Releasing a callback context while a callback or queued worker is still using it is a classic use-after-free.
The event’s rendered message is not its stable schema. Providers can localize descriptions, and a machine may not have the provider’s message resources installed even though the event record and payload are present. For durable ingestion, store the provider name and GUID where available, event ID, version, channel, record metadata, timestamp, and raw structured payload. Render a friendly message later for display, using the correct provider resources and locale. Do not parse an English message string as if it were a stable API field.
An event record ID is useful within a particular channel history but should not be treated as a globally unique primary key across every computer and log. Pair it with source machine identity, channel, provider, and the consumer’s query/cursor context. If downstream storage needs deduplication, define the key explicitly and test it through log clear/backup/restore scenarios. Record metadata can be reused after log lifecycle changes; identity should not be guessed from one number alone.
Keep the channel’s access and enablement assumptions visible. A subscription can fail because the caller cannot read the channel, the channel is disabled, the path is misspelled, or the query is invalid. Those are operationally different conditions from “no matching events.” Use a startup health probe that opens the intended channel, validates the query, and reports access failures distinctly. If a provider is optional, make its absence an explicit degraded state rather than returning an empty dataset that looks like healthy silence.
For push delivery, serialize updates to a shared bookmark and render each event’s cursor before its callback returns. The Event Log callback contract says a callback blocks later event notifications for that subscription, but downstream workers may still commit queued events out of order after callbacks return. A simple design uses one worker that processes one event at a time. If parallel work is needed for throughput, distinguish receipt order from completion order and persist only a cursor that is safe for the entire committed prefix; advancing past a slower earlier event can create a gap after restart.
Finally, bound payload storage. Event XML may include command lines, account names, paths, or other sensitive values. Apply retention and access policy to the ingested copy, not only to Event Viewer. Redact or hash fields that are unnecessary for the operational purpose, but preserve enough structured evidence to support the query and incident workflow the consumer was built to serve.
Operational verification
Test a cold start with no bookmark, a restart from a saved bookmark, duplicate delivery after a simulated crash, a stale bookmark after a channel clear, an unavailable remote source, a malformed query, callback queue saturation, and shutdown while events are arriving. Verify the consumer sees only the intended records and that log rotation produces an explicit gap state rather than a false success. Track last event time, last committed bookmark, query errors, callback latency, backlog, duplicate count, and stale-result count.
The Windows Event Log is a useful structured source when the consumer respects its actual guarantees. Keep queries narrow, make event processing idempotent, checkpoint after durable work, surface gaps, and use reconciliation or a central forwarding system when retention cannot bridge the consumer’s downtime.
Related:
- How to Centralize Windows Events with Windows Event Forwarding
- Event Tracing for Windows (ETW): The Kernel’s Built-In Instrumentation System
Sources:
- Subscribing to Events - Microsoft Learn
- EVT_SUBSCRIBE_CALLBACK callback function - Microsoft Learn
- Bookmarking Events - Microsoft Learn
- Consuming Events - Microsoft Learn
- EvtSubscribe function - Microsoft Learn
- EVT_SUBSCRIBE_FLAGS enumeration - Microsoft Learn
- EvtUpdateBookmark function - Microsoft Learn
- EvtRender function - Microsoft Learn
- EVT_RENDER_FLAGS enumeration - Microsoft Learn