winlog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Winlog
4
+ VERSION = "0.1.0"
5
+ end
data/lib/winlog.rb ADDED
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "winlog/version"
4
+ require "winlog/winlog" # native extension: Winlog::Provider + Error/Closed + new_activity_id
5
+ require "digest"
6
+
7
+ # winlog — structured, registration-free ETW TraceLogging telemetry for Ruby:
8
+ # events that cost nothing when nobody is listening.
9
+ #
10
+ # Register a provider by name (auto name-hashed GUID — no manifest, no message
11
+ # DLL, no registry write, no elevation) and emit runtime-dynamic, self-describing
12
+ # events decodable by WPA, PerfView, and the inbox logman/tracerpt with zero
13
+ # setup. When no ETW session has enabled the provider, a `log` call is gated in
14
+ # C before the fields are even looked at (~one Ruby method call). Emit-only:
15
+ # events go to ETW sessions, NOT to Event Viewer.
16
+ #
17
+ # PROV = Winlog.open("MyCompany.MyApp")
18
+ # PROV.log(:info, "Startup", version: "1.4.2", pid: Process.pid) # => false (no session)
19
+ # Winlog.open("Tool") { |p| p.log(:info, "Ran", args: ARGV.join(" ")) }
20
+ module Winlog
21
+ # Symbolic levels -> winmeta values (tracelogging.md §5, verified table).
22
+ LEVELS = {
23
+ critical: 1, # WINEVENT_LEVEL_CRITICAL
24
+ error: 2, # WINEVENT_LEVEL_ERROR
25
+ warn: 3, # WINEVENT_LEVEL_WARNING
26
+ info: 4, # WINEVENT_LEVEL_INFO
27
+ debug: 5, # WINEVENT_LEVEL_VERBOSE
28
+ verbose: 5 # alias of :debug
29
+ }.freeze
30
+
31
+ # Symbolic opcodes -> winmeta values. START/STOP bracket activities.
32
+ OPCODES = { info: 0, start: 1, stop: 2 }.freeze
33
+
34
+ # Keyword bits 48..63 are reserved by Microsoft; winlog REJECTS them.
35
+ KEYWORD_RESERVED_MASK = 0xFFFF_0000_0000_0000
36
+
37
+ # 16 signature bytes prepended to the name before SHA-1, per the documented
38
+ # ETW name-hash algorithm (tracelogging.md §4).
39
+ GUID_SIGNATURE = [0x48, 0x2C, 0x2D, 0xB2, 0xC3, 0x90, 0x47, 0xC8,
40
+ 0x87, 0xF8, 0x1A, 0x15, 0xBF, 0xC1, 0x30, 0xFB].freeze
41
+ private_constant :GUID_SIGNATURE
42
+
43
+ # Printable ASCII (bytes 0x21..0x7E): no spaces, no control chars, no NUL.
44
+ NAME_PATTERN = /\A[\x21-\x7E]{1,255}\z/
45
+ private_constant :NAME_PATTERN
46
+
47
+ # Base class for everything winlog raises itself. (Error and Closed are
48
+ # actually defined in C so they exist before this file's reopen; documented
49
+ # here for the reader.)
50
+ #
51
+ # class Winlog::Error < StandardError; end
52
+ # class Winlog::Closed < Winlog::Error; end
53
+
54
+ module_function
55
+
56
+ # Register a TraceLogging provider under +name+ and return a Winlog::Provider.
57
+ # Block form yields the provider and ensure-closes it, returning the block
58
+ # value. Registration is system-wide registration-FREE (no manifest, registry,
59
+ # or elevation). On a rare EventRegister failure NO exception is raised — the
60
+ # provider is a benign no-op; check #registered? (MS guidance).
61
+ #
62
+ # Winlog.open("X") # => #<Winlog::Provider X {guid}>
63
+ # Winlog.open("X") { |p| p.log(...) } # => block value; provider closed after
64
+ def open(name)
65
+ prov = Provider.new(name)
66
+ return prov unless block_given?
67
+
68
+ begin
69
+ yield prov
70
+ ensure
71
+ prov.close
72
+ end
73
+ end
74
+
75
+ # The ETW name-hashed GUID for +name+ WITHOUT registering anything. Pure Ruby
76
+ # (SHA-1 over the documented signature + UTF-16BE upcased name, .NET byte
77
+ # order). Same +name+ rules as Winlog.open. Case-insensitive.
78
+ #
79
+ # Winlog.guid_for("MyCompany.MyComponent")
80
+ # # => "ce5fa4ea-ab00-5402-8b76-9f76ac858fb5"
81
+ def guid_for(name)
82
+ name = validate_name!(name)
83
+ bytes = Digest::SHA1.digest(
84
+ GUID_SIGNATURE.pack("C*") + name.upcase.encode("UTF-16BE").b
85
+ ).bytes[0, 16]
86
+ bytes[7] = (bytes[7] & 0x0F) | 0x50
87
+ [bytes[0, 4].reverse, bytes[4, 2].reverse, bytes[6, 2].reverse,
88
+ bytes[8, 2], bytes[10, 6]]
89
+ .map { |part| part.map { |b| format("%02x", b) }.join }
90
+ .join("-")
91
+ end
92
+
93
+ # Map a level (Symbol in LEVELS or Integer 1..255) to its winmeta Integer.
94
+ # Raises ArgumentError on an unknown Symbol or out-of-range Integer.
95
+ def level_for(level)
96
+ case level
97
+ when Symbol
98
+ LEVELS.fetch(level) do
99
+ raise ArgumentError, "unknown level #{level.inspect} " \
100
+ "(known: #{LEVELS.keys.inspect})"
101
+ end
102
+ when Integer
103
+ unless level.between?(1, 255)
104
+ raise ArgumentError, "level must be 1..255, got #{level.inspect}"
105
+ end
106
+ level
107
+ else
108
+ raise ArgumentError,
109
+ "level must be a Symbol or Integer, got #{level.inspect}"
110
+ end
111
+ end
112
+
113
+ # Map an opcode (Symbol in OPCODES or Integer 0..255) to its Integer value.
114
+ def opcode_for(opcode)
115
+ case opcode
116
+ when Symbol
117
+ OPCODES.fetch(opcode) do
118
+ raise ArgumentError, "unknown opcode #{opcode.inspect} " \
119
+ "(known: #{OPCODES.keys.inspect})"
120
+ end
121
+ when Integer
122
+ unless opcode.between?(0, 255)
123
+ raise ArgumentError, "opcode must be 0..255, got #{opcode.inspect}"
124
+ end
125
+ opcode
126
+ else
127
+ raise ArgumentError,
128
+ "opcode must be a Symbol or Integer, got #{opcode.inspect}"
129
+ end
130
+ end
131
+
132
+ # Validate a provider name: printable ASCII (0x21..0x7E), 1..255 bytes.
133
+ # Returns the name as a String. Raises ArgumentError on anything else.
134
+ def validate_name!(name)
135
+ str = String(name)
136
+ # Compare raw bytes so a broken/foreign encoding can never make the regex
137
+ # match raise (it would on an invalid byte sequence); printable ASCII is a
138
+ # pure byte predicate. Reuse the same 0x21..0x7E, 1..255 contract as
139
+ # NAME_PATTERN, then return a clean UTF-8 String.
140
+ bytes = str.bytes
141
+ ok = bytes.length.between?(1, 255) && bytes.all? { |b| b >= 0x21 && b <= 0x7E }
142
+ unless ok
143
+ raise ArgumentError,
144
+ "name must be 1..255 printable-ASCII bytes (0x21..0x7E: no spaces, " \
145
+ "control chars, or NUL), got #{name.inspect}"
146
+ end
147
+ str.b.force_encoding(Encoding::UTF_8)
148
+ end
149
+ private_class_method :validate_name!
150
+
151
+ # Native (TypedData) provider class; the C side defines the bridges and the
152
+ # read-only methods. The Ruby layer here adds the validated, ergonomic API.
153
+ class Provider
154
+ # The provider name as given (frozen UTF-8 String). Stored at initialize and
155
+ # never read back from provider metadata (tld swaps in empty NullMetadata on
156
+ # a failed registration). Correct regardless of registration outcome.
157
+ def name
158
+ raise Winlog::Closed, "winlog: provider is closed" if closed?
159
+
160
+ @name
161
+ end
162
+
163
+ # Same contract as Winlog.open (no block form here; open delegates to new).
164
+ # Raises ArgumentError on a bad name; never raises on EventRegister failure
165
+ # (see #registered?).
166
+ def initialize(name)
167
+ @name = Winlog.send(:validate_name!, name).dup.freeze
168
+ _register(@name)
169
+ end
170
+
171
+ # Emit one TraceLogging event. THE call of the gem.
172
+ #
173
+ # level : Symbol in LEVELS, or Integer 1..255.
174
+ # event : event name (Symbol or String; valid UTF-8, non-empty, no NUL).
175
+ # fields : keyword splat of field-name => value pairs, plus the reserved
176
+ # control kwargs keyword:/opcode:/activity:/related: (extracted by
177
+ # exact Symbol key; String keys with the same text stay fields).
178
+ #
179
+ # Returns true if enabled and written, false if skipped/dropped. Never
180
+ # raises on delivery problems (ETW is lossy by design). Raises Winlog::Closed
181
+ # after close. Field-value type errors raise only when the event is enabled
182
+ # (the deliberate E2 asymmetry; control args validate on every call).
183
+ def log(level, event, **fields)
184
+ # Map opcode symbol -> integer here (Ruby owns the OPCODES table) without
185
+ # disturbing the String-key escape hatch: only a :opcode Symbol key is a
186
+ # control arg, and only when its value is a Symbol does it need mapping.
187
+ if fields.key?(:opcode) && fields[:opcode].is_a?(Symbol)
188
+ fields = fields.dup
189
+ fields[:opcode] = Winlog.opcode_for(fields[:opcode])
190
+ end
191
+ _log(Winlog.level_for(level), event, fields)
192
+ end
193
+
194
+ # Is any ETW session listening (with the given level/keyword filter)?
195
+ # Reads in-process enable state — no system call. Raises Winlog::Closed
196
+ # after close.
197
+ #
198
+ # level: nil (default; "any level") | Symbol in LEVELS | Integer 1..255
199
+ # keyword: Integer 0..0x0000_FFFF_FFFF_FFFF (reserved bits raise)
200
+ def enabled?(level: nil, keyword: 0)
201
+ lvl = level.nil? ? nil : Winlog.level_for(level)
202
+ _enabled(lvl, keyword)
203
+ end
204
+
205
+ # Never raises, even closed.
206
+ def inspect
207
+ if closed?
208
+ "#<Winlog::Provider (closed)>"
209
+ else
210
+ "#<Winlog::Provider #{@name} {#{guid}}>"
211
+ end
212
+ end
213
+
214
+ private :_register, :_log, :_write, :_enabled
215
+ end
216
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: winlog
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vcvars
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.1.1
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.1.1
74
+ description: |
75
+ winlog emits Windows ETW TraceLogging events from Ruby with no manifest, no
76
+ message DLL, no registry writes, and no elevation: register a provider by
77
+ name (standard ETW name-hashed GUID), then log runtime-dynamic events with
78
+ typed fields (UTF-8 text, binary, int64, double, boolean) plus levels,
79
+ keywords, opcodes, and explicit activity IDs for correlation. Events are
80
+ self-describing and decode in WPA, PerfView, and the inbox logman/tracerpt
81
+ with zero setup; when no session is listening, a log call is gated in native
82
+ code before field processing and costs about one Ruby method call. Built on
83
+ Microsoft's MIT-licensed TraceLoggingDynamic.h (vendored). Emit-only: events
84
+ go to ETW sessions, not the Windows Event Log. Windows MSVC (mswin) Ruby only.
85
+ executables: []
86
+ extensions:
87
+ - ext/winlog/extconf.rb
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - LICENSE.txt
92
+ - README.md
93
+ - ext/winlog/TraceLoggingDynamic.h
94
+ - ext/winlog/extconf.rb
95
+ - ext/winlog/winlog.cpp
96
+ - lib/winlog.rb
97
+ - lib/winlog/version.rb
98
+ homepage: https://github.com/main-path/winlog
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://github.com/main-path/winlog
103
+ source_code_uri: https://github.com/main-path/winlog
104
+ changelog_uri: https://github.com/main-path/winlog/blob/main/CHANGELOG.md
105
+ bug_tracker_uri: https://github.com/main-path/winlog/issues
106
+ rubygems_mfa_required: 'true'
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '3.0'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.6.9
122
+ specification_version: 4
123
+ summary: Structured, registration-free ETW TraceLogging telemetry for Ruby — events
124
+ that cost nothing when nobody is listening.
125
+ test_files: []