winlog

Structured, registration-free ETW telemetry for Ruby — TraceLogging events that cost nothing when nobody is listening.

winlog registers a TraceLogging provider by name (auto name-hashed GUID — no manifest, no message DLL, no registry write, no elevation) and emits runtime-dynamic, self-describing events: an event name and typed fields decided at call time, decodable by WPA, PerfView, and the inbox logman/tracerpt with zero setup.

The pain it removes: you cannot leave puts debugging or verbose file logging on in production, and a log file is an unstructured island disconnected from the rest of the system's diagnostics. ETW is the structured, OS-native tracing pipeline that Windows itself and the .NET runtime emit into — but reaching it from a scripting language has historically meant authoring a manifest, compiling a message DLL, and registering it with admin rights. TraceLogging is the manifest-free encoding that fixes this: every event carries its own schema, so it decodes without any registration. winlog is the thin Ruby binding to it.

  • No setup, anywhere. gem install winlog, Winlog.open, log. There is no registration step the gem performs on the system, and no other Ruby gem emits native ETW. Logger writes files; this feeds WPA/PerfView timelines right next to kernel and .NET events.
  • A disabled log costs about one Ruby method call. When no ETW session has enabled the provider, the call is gated in native code before the fields are even looked at — no allocation, no transcoding, no iteration. Leave it in production; that is the entire point of ETW.
  • Hard to misuse. Reserved keyword bits, level 0, malformed activity GUIDs, NUL bytes in names, and unknown level symbols all raise loudly. Delivery problems never raise — ETW is lossy by design, so log returns a boolean.
What API
Register a provider Winlog.open("MyCompany.MyApp") (block form auto-closes)
Emit an event provider.log(:info, "Event", field: value, ...)
Cheap "is anyone listening?" provider.enabled?(level: :debug, keyword: 0x1)
Name-hashed GUID (no register) Winlog.guid_for("MyCompany.MyApp")
Fresh correlation id Winlog.new_activity_id
Stop / free provider.close

Requirements

  • Windows 10 or later with a native MSVC (mswin) Ruby. Not supported on MinGW/UCRT Ruby (the extension is built with cl.exe).
  • Visual Studio 2017+ / Build Tools with the Desktop development with C++ workload to build from source. The vcvars dev dependency loads the MSVC environment for rake compile automatically — no Developer Command Prompt needed; point build failures at vcvars doctor.
  • x64. arm64-mswin is expected to work but is untested and unsupported until an arm64-mswin Ruby distribution exists (the code is arch-neutral).

Install

gem install winlog

Quick start

require "winlog"

# EXE-lifetime provider: open once at startup, never close (the kernel cleans
# up registration at process exit; the GC free hook is a safety net).
PROV = Winlog.open("MyCompany.MyApp")
PROV.guid                                  # => "ce5fa4ea-..."; hand to logman as "{...}"

PROV.log(:info, "Startup", version: "1.4.2", pid: Process.pid)   # => false (no session)

# Skip building expensive arguments when nobody is listening:
if PROV.enabled?(level: :debug)
  PROV.log(:debug, "CacheDump", entries: cache.size, hot: cache.hot_keys.join(","))
end

# Activity correlation (fiber-safe: explicit IDs, never the thread-ambient one):
aid = Winlog.new_activity_id
PROV.log(:info, "JobStart", opcode: :start, activity: aid, job: "reindex")
PROV.log(:info, "JobStop",  opcode: :stop,  activity: aid, ok: true)

# Misuse raises loudly:
PROV.log(:fatal, "X")                       # ArgumentError: unknown level :fatal
PROV.log(:info, "X", keyword: 1 << 50)      # ArgumentError: reserved keyword bits
Winlog.open("päivä")                        # ArgumentError: name must be printable ASCII

# Scoped provider for a short-lived tool:
Winlog.open("MyCompany.Tool") { |p| p.log(:info, "Ran", args: ARGV.join(" ")) }

log returns true only when a session had the provider enabled at that level+keyword and the write succeeded; otherwise false (no listener, the provider never registered, or ETW dropped the event). It never raises on delivery.

Seeing your events

Collecting a trace requires an elevated prompt (or membership in the Performance Log Users group). This is inherent to ETW for every provider on Windows, including Microsoft's own — it is not a winlog property and concerns collection, never emit. Ranked, copy-pasteable:

1. Inbox only — logman + tracerpt (nothing to install). logman does not name-hash, so give it provider.guid:

logman start rb -p "{ce5fa4ea-ab00-5402-8b76-9f76ac858fb5}" 0xffffffffffffffff 5 -o rb.etl -ets
ruby app.rb
logman stop rb -ets
tracerpt rb.etl -o rb.xml          # TDH auto-decodes TraceLogging on Win10+

2. WPR (inbox) + WPA (free). A .wprp profile can name the provider with the star (*) syntax, which name-hashes for you:

<EventProvider Id="MyApp" Name="*MyCompany.MyApp" />

wpr -start profile.wprp -filemodewpr -stop out.etl → open in WPA → System Activity ▸ Generic Events.

3. PerfView (single-exe download): PerfView /onlyProviders=*MyCompany.MyApp collect. The * converts the name to its GUID the EventSource way.

Events do NOT appear in Event Viewer / wevtutil. That requires registering the provider with an Event Log channel (a manifest + wevtutil im, i.e. registration and admin) — exactly what winlog deliberately does not do. TraceLogging events go to ETW sessions, decoded by the tools above.

Levels, keywords, opcodes

Winlog::LEVELS   # critical:1 error:2 warn:3 info:4 debug:5 (verbose: alias of debug)
Winlog::OPCODES  # info:0 start:1 stop:2
  • Level is a Symbol from LEVELS or an Integer 1..255 (1..5 are the standard winmeta levels). Level 0 is rejected — always assign a meaningful non-zero level.
  • Keyword is a 64-bit bitmask; bits 0..47 are yours, bits 48..63 are reserved by Microsoft (Winlog::KEYWORD_RESERVED_MASK) and setting any of them raises ArgumentError. The default is 0, which bypasses keyword filtering (ETW semantics) — fine for getting started, but assign meaningful keywords so sessions can filter.
  • Opcode :start/:stop bracket an activity for decoders.

Activities and fibers

Correlate related events by passing the same explicit activity id, generated with Winlog.new_activity_id:

aid = Winlog.new_activity_id
prov.log(:info, "RequestStart", opcode: :start, activity: aid, related: parent_aid)
# ... work ...
prov.log(:info, "RequestStop",  opcode: :stop,  activity: aid, status: 200)

winlog never uses or mutates ETW's thread-ambient activity id. Under a fiber scheduler such as winloop, one thread hosts many fibers, and the thread-local id would smear one fiber's activity across all of them. Correlation is therefore always per-call and explicit, which is fiber-correct by construction. If you want an ambient pattern, store the id in Thread.current[:winlog_activity] (fiber-local in Ruby) and pass it yourself.

winlog never blocks, so it never offloads to a worker thread and integrates with a scheduler trivially: log/enabled?/open/close run inline, occupying the loop thread for at most one ~1–3 µs EventWriteTransfer per enabled event.

Costs and validation

Operation Cost (spike-measured, x64)
enabled? native check (IsEnabled) ~0.4 ns
Disabled log (gated, fields untouched) ~one Ruby method call (~0.1 µs)
Enabled EventWriteTransfer ~1–3 µs/event (expected; measure under a live session)

The deliberate validation asymmetry (E2). To keep the disabled path free, a log call validates its control arguments on every call — level, event-name type/content/NUL, keyword:/opcode:/activity:/related: — but does not look at field values until the event is actually enabled. Consequently a bad field value (e.g. nil, an Array) raises TypeError only when a session is attached. Use the enabled? guard idiom above if you need expensive arguments built only when watched, and rely on the control-arg checks to keep the common bugs deterministic regardless.

Field-value → ETW type mapping:

Ruby value ETW type notes
String (text encoding) UTF-8 string transcoded; embedded NUL or invalid bytes → ArgumentError
String (Encoding::BINARY) binary UINT16-counted; > 65535 bytes → ArgumentError
Integer int64 outside INT64 → RangeError
Float double
true / false bool32
anything else (incl. nil) TypeError

Errors

Winlog::Error           < StandardError   # base for what winlog raises itself
└── Winlog::Closed                        # any op (except close/closed?/inspect) on a closed provider

Plain argument misuse raises Ruby's own ArgumentError / TypeError / RangeError. There is deliberately no OSError: registration failure is a queryable status (registered? is false, registration_result is the HRESULT), and write failure is a false return — ETW is lossy by design and Microsoft's guidance is to ignore both. The single OS call with no status channel, EventActivityIdControl inside Winlog.new_activity_id, raises Winlog::Error with the Win32 code (it cannot fail in practice).

How it works

winlog is built on Microsoft's TraceLoggingDynamic.h (vendored into ext/winlog/, © Microsoft, MIT). TraceLoggingProvider.h — the usual header — requires compile-time-constant provider/event/field names, which a runtime Ruby log(level, event, **fields) API cannot supply; TraceLoggingDynamic.h is Microsoft's supported answer for runtime-dynamic events, and (being C++: templates + std::vector) makes winlog the suite's first C++ extension.

Under the hood: EventRegister opens a REGHANDLE and EventSetInformation attaches the provider traits; the provider GUID is the standard ETW name-hash of the name (SHA-1 over a fixed signature + the UTF-16BE upper-invariant name, .NET byte order — the same GUID EventSource, WPR *name, PerfView, and Winlog.guid_for compute). Each log builds one self-describing event (metadata

  • packed field data) and writes it with EventWriteTransfer. enabled? reads in-process state maintained by ETW's enable callback — no system call.

Honest limitations:

  • Lossy transport. Even a true return does not guarantee a session retained the event (buffers can be full); a false may mean disabled, unregistered, or dropped.
  • 64 KB event ceiling (ETW), 65535-byte binary fields (UINT16-counted), and a ~32 KB metadata cap. Oversized events return false, never raise.
  • Emit-only. No session control, no consumption/decoding, no Event Log delivery. The five field types above are the whole v1 surface.

License

MIT. The vendored ext/winlog/TraceLoggingDynamic.h is © Microsoft Corporation, also MIT.