July 15, 2025

Windows Event Viewer in the Browser

For the last weekend hacking I decided to pursue an old interest. I maintain an EVTX parser in Rust and have always wanted to plug it into some kind of WASM context.

Since it's now possible to just vibe things I thought it would be a nice idea to create a Windows Event Viewer clone that runs entirely in the browser — with some extra filtering tricks.

We ended up with:

  • Logs streaming from parsing EVTX → DuckDB via IPC in Arrow, all in the browser.
  • Classy Windows-style UI elements in React.
  • Some surprising filtering prowess.

#Initial mock-up

For those of you who aren't familiar with the Windows Event Viewer, it's this lovely interface:

Original Windows Event Viewer

Armed with that screenshot I vibe-coded a React-based clone. When I asked for Windows aesthetics the models pulled a Windows 11 icon kit, so the result kind of looks like this:

React clone of the Event Viewer

I also replaced the sidebar (right) with a facet-counting (à la Datadog) widget while keeping the native Windows feel.

Starting initially I added a naïve set of bindings that would basically load the entire file into memory and convert it into a JavaScript object:

#[wasm_bindgen]
pub fn parse_all(&self) -> Result<JsValue, JsError> {
    let cursor = Cursor::new(&self.data);
    let mut parser = EvtxParser::from_read_seek(cursor)?
        .with_configuration(settings);
 
    let mut records = Vec::new();
 
    for record in parser.records_json_value() {
        if let Ok(record_data) = record {
            records.push(record_data.data);
        }
    }
 
    let result = ParseResult {
        total_records: records.len(),
        records,
        // ...
    };
 
    serde_wasm_bindgen::to_value(&result)
}

The table itself is using @tanstack/virtual — a no-brainer if we want to scroll tens of thousands of logs and keep the DOM from imploding.

In the first version we kept all of the parsed records in memory but not in the DOM. This actually worked quite well for less than 1 million records 🙂


#Virtualisation & lazy loading

But alas, the void calls — why waste all this precious memory? EVTX files are stored in chunks of 64 KB, each independently parsable. Perhaps we could load the file chunk-by-chunk and hook into virtualisation to lazily parse the chunks. That would be neat.

export class LazyEvtxReader {
  private parser: EvtxWasmParser;
  private cache: Map<string, EvtxRecord[]> = new Map();
 
  async getWindow(win: ChunkWindow): Promise<EvtxRecord[]> {
    const key = `${win.chunkIndex}-${win.start}-${win.limit}`;
 
    if (this.cache.has(key)) {
      return this.cache.get(key)!;
    }
 
    const res = this.parser.parse_chunk_records(
      win.chunkIndex,
      win.start,
      win.limit
    );
 
    const records = res.records.map(r => this.mapToObject(r));
    this.cache.set(key, records);
 
    return records;
  }
}
const virtualizer = useVirtualizer({
  count: chunkCount,
  getScrollElement: () => containerRef.current,
  estimateSize: (idx) => {
    const recordCount = chunkRecordCounts[idx] ?? estimateRowsPerChunk;
    return rowHeight * recordCount;
  },
  overscan: 2,
});
 
// Load visible chunks as the user scrolls
useEffect(() => {
  virtualizer.getVirtualItems().forEach((item) => {
    ensureChunk(item.index);
  });
}, [virtualizer.getVirtualItems()]);

With that we could scroll a bunch of logs and even implement basic filtering.


#Facet counting

However, the true potential of having a filtering UI is doing some facet statistics and manipulation.

When looking at event logs you usually want to trace sessions, find outliers, etc. For example, looking at an example log file, it's interesting to think about what the odd ones out are — the events that appear less frequently:

Facet counting UI

I implemented basic facet counting in Rust:

#[wasm_bindgen]
pub fn compute_buckets(data: &[u8]) -> Result<JsValue, JsError> {
    let mut parser = EvtxParser::from_read_seek(Cursor::new(data))?;
    let mut buckets = BucketCounts::default();
 
    for record in parser.records_json_value() {
        if let Ok(r) = record {
            let sys = r.data.get("Event")
                .and_then(|v| v.get("System"));
 
            // Count by Level
            if let Some(level) = sys.and_then(|s| s.get("Level")) {
                *buckets.level.entry(level.to_string()).or_insert(0) += 1;
            }
 
            // Count by Provider
            if let Some(name) = sys
                .and_then(|s| s.get("Provider"))
                .and_then(|p| p.get("Name")) {
                *buckets.provider.entry(name.as_str().unwrap().to_owned()).or_insert(0) += 1;
            }
 
            // Similar for Channel & EventID...
        }
    }
 
    serde_wasm_bindgen::to_value(&buckets)
}

Playing with it revealed one problem: what if we apply a filter? That would render all the facet counts invalid. We could naïvely rescan the whole file — but that gets annoying with large logs.

We needed some kind of index — or actually a database.


#DuckDB to the rescue

DuckDB is an embedded analytics database, which means (in theory) we can load it into a browser tab, feed it the records and let it serve as an index so we can slice and dice our data.

Naïvely we could just insert the logs into DuckDB, but that creates overhead because we'd need to materialise all logs as TypeScript objects — essentially straining the browser by creating a ton of objects:

Heap usage when inserting via JS objects

A neat optimisation is to have both WASM sandboxes communicate over IPC using Apache Arrow:

Arrow IPC between Rust and DuckDB

Here's how we serialise a chunk to Arrow IPC on the Rust side:

#[wasm_bindgen]
pub fn chunk_arrow_ipc(&self, chunk_index: usize) -> Result<ArrowChunkIPC, JsError> {
    // Prepare column builders
    let mut eid_builder = MutablePrimitiveArray::<i32>::new();
    let mut level_builder = MutablePrimitiveArray::<i32>::new();
    let mut provider_builder = MutableUtf8Array::<i32>::new();
    let mut channel_builder = MutableUtf8Array::<i32>::new();
    let mut raw_builder = MutableUtf8Array::<i32>::new();
 
    // Parse the requested chunk
    let chunk = self.get_chunk(chunk_index)?;
 
    for record in chunk.records() {
        let json = record.to_json();
 
        // Extract fields and push to builders
        eid_builder.push(extract_event_id(&json));
        level_builder.push(extract_level(&json));
        provider_builder.push(Some(extract_provider(&json)));
        channel_builder.push(Some(extract_channel(&json)));
        raw_builder.push(Some(json.to_string()));
    }
 
    // Create Arrow batch
    let batch = Chunk::new(vec![
        eid_builder.as_box(),
        level_builder.as_box(),
        provider_builder.as_box(),
        channel_builder.as_box(),
        raw_builder.as_box(),
    ]);
 
    // Serialise to IPC format
    let mut buf = Vec::new();
    let mut writer = StreamWriter::new(&mut buf, WriteOptions { compression: None });
    writer.start(&schema, None)?;
    writer.write(&batch, None)?;
    writer.finish()?;
 
    Ok(ArrowChunkIPC { bytes: buf, rows: batch.len() })
}

And on the TypeScript side, we consume it directly:

export async function startFullIngest(
  reader: LazyEvtxReader,
  onProgress?: (pct: number) => void
): Promise<void> {
  const { totalChunks } = await reader.getFileInfo();
  await initDuckDB();
 
  for (let i = 0; i < totalChunks; i++) {
    // Get Arrow IPC buffer from Rust
    const { buffer } = await reader.getArrowIPCChunk(i);
 
    // Feed directly to DuckDB
    await insertArrowIPC(buffer);
 
    onProgress?.((i + 1) / totalChunks);
 
    // Let the UI breathe
    await new Promise(r => requestAnimationFrame(r));
  }
}
 
export async function insertArrowIPC(buffer: Uint8Array): Promise<void> {
  const conn = await initDuckDB();
  conn.insertArrowFromIPCStream(buffer, {
    name: "logs",
    append: true,
    create: false,
  });
}

#Slice & dice

Virtualisation was a breeze to set up with DuckDB:

export async function fetchTabular(
  columns: ColumnSpec[],
  filters: FilterOptions,
  limit = 100,
  offset = 0
): Promise<Record<string, unknown>[]> {
  const conn = await initDuckDB();
 
  const select = columns.map(col =>
    `${col.sqlExpr} AS "${col.id}"`
  ).join(", ");
 
  const where = buildWhere(filters);
  const query = `
    SELECT ${select}, Raw
    FROM logs
    ${where ? `WHERE ${where}` : ""}
    LIMIT ${limit} OFFSET ${offset}
  `;
 
  return conn.query(query).toArray();
}

We get all of our filtering applied server-side (sweet), which enables some really nice — and otherwise non-trivial — features:

Filtering UI powered by DuckDB

Internally it maps to something like:

// EventData field filtering using DuckDB's JSON functions
if (filters.eventData) {
  for (const [field, values] of Object.entries(filters.eventData)) {
    if (!values?.length) continue;
 
    const valueList = values
      .map(v => `'${escapeSqlString(v)}'`)
      .join(",");
 
    clauses.push(
      `json_extract_string(Raw, '$.Event.EventData.${field}') IN (${valueList})`
    );
  }
}

Everything runs quite a bit faster than I initially expected!


#Takeaways

The browser is a workhorse in 2025. It's an amazing way to distribute cross-platform apps, and they can be much more powerful than you'd expect.

This runs on every PC, is fully portable, requires no installation, no compilation. You could drop into an incident response scenario and whip this up from a Mac/Linux/PC/iPad.

Also, the fact that all sites look the same today is kind of a bummer — the dense UIs of the past feel much more effective than the airy ones we get everywhere now.

Hope one of you may find this useful one day!