Capture ETW events with C++ (Part 1)

Author

Pavel Yosifovich has 25+ years as Software developer, trainer, consultant, author, and speaker. Co-author of “Windows Internals”. Author of “Windows Kernel Programming”, “Windows 10 System Programming, as well as System and kernel programming courses and “Windows Internals” series.

Introduction

ETW (Event Tracing for Windows) is the telemetry backbone of the entire Windows ecosystem. Drivers use it. System components use it. Every serious security tool reads from it at some point. And yet, most developers who work on Windows have never touched the ETW APIs directly.

This post walks through the mechanics of consuming ETW events in real-time using plain Win32 C++. No wrappers, no abstractions. Just the actual API calls, what they require, and what can go wrong. I’m assuming you’re already familiar with the basics of ETW: what a provider is, what a session is, and generally how events flow. If not, take a look at the introductory ETW video first, then come back here.

Getting the Provider GUID

Every registered ETW provider has a GUID. That’s the identifier you pass to the APIs, not a human-friendly name but an actual 16-byte structure. Technically, you could enumerate all providers using TdhEnumerateProviders and resolve a name to a GUID programmatically. For our purposes here, we’ll take the GUID directly from the command line and let the caller figure out which provider they want.

There are a couple of tools that make finding a GUID straightforward. One is ETW Explorer, which lets you browse all registered providers on the machine, see their events, and inspect the properties of each one. Another option is logman query providers from an elevated command prompt. Pipe that through grep and you’ll find what you need quickly enough.

Once you have the GUID as a string (the {xxxxxxxx-xxxx-...} format), you convert it using CLSIDFromString. The name is a bit misleading. This is a COM function, but a CLSID is just a GUID, so it does exactly what we need. It takes a Unicode string and fills a GUID structure.

The function requires a Unicode string, which is why we use wmain instead of main.

Creating a Trace Session

The first real piece of work is starting a trace session. The function is StartTrace, and it needs three things: a handle that will receive the session identifier, a session name, and a pointer to an EVENT_TRACE_PROPERTIES structure.

The session name just has to be unique across the system. If a session with that name already exists, StartTrace will fail with ERROR_ALREADY_EXISTS. We’ll deal with that in a moment.

The EVENT_TRACE_PROPERTIES structure is where things get slightly unfortunate. You can’t just allocate one and fill it in. The structure needs extra space appended after it for the session name string, because Windows will copy the name into that offset. So the actual allocation looks like this:

const auto sessionName = L"YouTubeSession";
const auto size = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(WCHAR) * (wcslen(sessionName) + 1);
auto buffer = std::make_unique<BYTE[]>(size);
auto props = reinterpret_cast<EVENT_TRACE_PROPERTIES*>(buffer.get());
memset(props, 0, size);
props->Wnode.BufferSize = static_cast<ULONG>(size);
props->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
props->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

The BufferSize field in Wnode needs to reflect the entire allocation, including the extra space. LogFileMode is set to real-time, meaning events go directly to any active consumer rather than being written to a file. And LoggerNameOffset tells Windows where to write the session name inside the buffer, right after the structure itself.

That’s really the minimum you need to call StartTrace. Plenty of other fields exist for tuning buffer sizes, flush intervals, and so on. We’re ignoring all of that here.

Handling “Session Already Exists”

StartTrace returns ERROR_ALREADY_EXISTS when a session with that name is already running. This happens more often than you’d think during development. You break out of a debugging session and the ETW session keeps running on its own. It has a life of its own, so to speak.

The simplest fix is to stop the existing session and start fresh. ControlTrace does that:

TRACEHANDLE hTrace{};
auto status = StartTrace(&hTrace, sessionName, props);
if (status == ERROR_ALREADY_EXISTS) {
    ControlTrace(0, sessionName, props, EVENT_TRACE_CONTROL_STOP);

    // Re-initialize props before calling StartTrace again
    memset(props, 0, size);
    props->Wnode.BufferSize = static_cast<ULONG>(size);
    props->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
    props->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

    status = StartTrace(&hTrace, sessionName, props);
}

After calling ControlTrace to stop the session, reinitialize props before passing it to StartTrace again. The stop operation can modify the structure’s contents, and you don’t want to start a new session with whatever state was left behind.

Enabling a Provider

Once the session exists, it has no providers, which means it collects nothing. You add a provider using EnableTraceEx2, which is the most flexible variant. There’s also EnableTrace and EnableTraceEx, each slightly less capable. Use EnableTraceEx2.

EnableTraceEx2(
    hTrace,
    &providerGuid,
    EVENT_CONTROL_CODE_ENABLE_PROVIDER,
    TRACE_LEVEL_VERBOSE,
    0,   // MatchAnyKeyword - 0 means all keywords
    0,   // MatchAllKeyword
    0,   // Timeout
    nullptr  // EnableParameters
);

TRACE_LEVEL_VERBOSE means you want everything at verbose level or below, which in practice means all events. You can filter by level if you only care about warnings and above, for example. The keyword parameters let you filter by event category, which is useful when a provider is noisy. Setting both to zero means no filtering.

The EnableParameters argument is where you could ask for stack traces per event, among other things. Passing nullptr means no extras.

Windows master developer badge 1

$2,111

$1,478 or $150 X 10 payments

Windows Master Developer

Takes you from a “generic” C programmer to a master Windows programmer in user mode and kernel mode.

Opening a Consumer

The session is running and the provider is enabled. Events are flowing into ETW’s internal buffers. But we’re not receiving anything yet. We haven’t set up a consumer.

That’s done through OpenTrace, which takes an EVENT_TRACE_LOGFILE structure:

EVENT_TRACE_LOGFILE etl{};
etl.LoggerName = const_cast<PWSTR>(sessionName);
etl.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
etl.EventRecordCallback = OnEvent;
TRACEHANDLE hParse = OpenTrace(&etl);

The LoggerName field tells OpenTrace which session to connect to. ProcessTraceMode has two flags: REAL_TIME means we’re not reading from a file, and EVENT_RECORD switches to the modern callback style. That means EventRecordCallback instead of the older EventCallback. Always use the event record style. The older callback exists for backwards compatibility and isn’t worth the trouble.

Processing Events

With the consumer open, you start receiving events by calling ProcessTrace:

ProcessTrace(&hParse, 1, nullptr, nullptr);

This call never returns. It enters a loop and dispatches your callback for every event that arrives. In a real application, you’d run this on a dedicated thread so the rest of your application stays responsive. To stop it, you call CloseTrace from another thread, which causes ProcessTrace to return.

For our purposes here, the application just blocks until you kill it with Ctrl+C.


The Event Callback

The callback receives a pointer to an EVENT_RECORD for every event. EventHeader inside it gives you the basics: process ID, thread ID, provider GUID, and a timestamp.

void WINAPI OnEvent(PEVENT_RECORD rec) {
    const auto& header = rec->EventHeader;
    // Convert timestamp to local time
    FILETIME ft{};
    FileTimeToLocalFileTime(
        reinterpret_cast<const FILETIME*>(&header.TimeStamp), &ft);
    SYSTEMTIME st{};
    FileTimeToSystemTime(&ft, &st);
    printf("[%02d:%02d:%02d.%03d] PID: %lu  TID: %lu\n",
        st.wHour, st.wMinute, st.wSecond, st.wMilliseconds,
        header.ProcessId, header.ThreadId);
}

The timestamp is a 64-bit value in 100-nanosecond units from January 1st, 1601, in UTC. Yes, 1601. This is the same format as FILETIME, which is not a coincidence. The cast is safe since the memory layout is identical, just different struct names for historical reasons.

FileTimeToLocalFileTime converts from UTC to local time, then FileTimeToSystemTime breaks it down into hours, minutes, seconds, and milliseconds, something you can actually read.

Getting the actual event properties — the task name, the field values, the process name for a process creation event — requires the Trace Data Helper (TDH) API. That’s where things get considerably more involved, and we’ll cover that in Part 2.

Key Takeaways

  • EVENT_TRACE_PROPERTIES requires an oversized allocation. The session name is stored after the structure itself. Get the size calculation wrong and things will fail in non-obvious ways.
  • StartTrace fails with ERROR_ALREADY_EXISTS if the session name is already in use. Reinitialize props before retrying.
  • Use EnableTraceEx2. It’s the most capable variant and supports stack trace capture and other extensions via EnableParameters.
  • ProcessTrace blocks the calling thread indefinitely. Use a dedicated thread in any real application.
  • Timestamp conversion requires two steps: FileTimeToLocalFileTime then FileTimeToSystemTime. The raw TimeStamp field in EventHeader is UTC and not human-readable.
  • Most ETW operations require admin privileges. You’ll get ERROR_ACCESS_DENIED (error code 5) if you forget to run elevated.

Keep Learning

If you want to go deeper into Windows system programming and the APIs that underlie ETW, take a look at the Windows System Programming 1 and Windows System Programming 2 courses at TrainSec. They cover the Win32 layer in detail, including handles, memory management, threading, and synchronization.

For the kernel-side picture, the Windows Internals series walks through how ETW is implemented beneath the API. You can start with Windows Internals: Day 1.

Part 2 of this series will cover extracting event properties using TDH, getting the actual field values out of an EVENT_RECORD.

Go to PART 2: https://trainsec.net/library/windows-internals/capture-etw-events-with-c-part-2

Liked the content?

Subscribe to the free TrainSec knowledge library, and get insider access to new content, discounts and additional materials.

blue depth

About the author

Pavel Yosifovich has 25+ years as Software developer, trainer, consultant, author, and speaker. Co-author of “Windows Internals”. Author of “Windows Kernel Programming”, “Windows 10 System Programming, as well as System and kernel programming courses and “Windows Internals” series.
Even more articles from the free knowledge library
Writing a Simple Key Logger

In this video, Pavel walks through how to implement a basic keylogger in Windows using GetKeyState, handling character normalization (Shift,

Read More