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.Loggerwrites files; this feeds WPA/PerfView timelines right next to kernel and .NET events. - A disabled
logcosts 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
logreturns 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
vcvarsdev dependency loads the MSVC environment forrake compileautomatically — no Developer Command Prompt needed; point build failures atvcvars 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 -filemode → wpr -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 whatwinlogdeliberately 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
LEVELSor an Integer1..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 raisesArgumentError. The default is0, which bypasses keyword filtering (ETW semantics) — fine for getting started, but assign meaningful keywords so sessions can filter. - Opcode
:start/:stopbracket 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
truereturn does not guarantee a session retained the event (buffers can be full); afalsemay 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.