philiprehberger-log_filter 0.6.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a05316f59bd1b7ea9c31dad53105a3febd50bac5c0b274257838ebf2e8e66f0
4
- data.tar.gz: acd65cf197ebb2037dffc890d7be274e0dbd986cfc10de7270efb1cc922232a3
3
+ metadata.gz: a18a7f3be999eaccf0508fb12df28e38684d0a807e3eea4837e40ed55cc903e5
4
+ data.tar.gz: 4a0fd034904e1fafa47fbecf687d5ea01b9569f6d9720c96bc1f711b7e6298fd
5
5
  SHA512:
6
- metadata.gz: cfc4d1c293fbdcef1fc8249f00d9cd3739d903f32b93586aaa64183f3c8f35d1bc7973d2762319529834ed936d9aebcdb934c9e91eaecbc753928054ae33f073
7
- data.tar.gz: c1f741854430ca2d1c0925bc6fd04e5c06cc3ccb8542802ee2ca379d8bf2c4726ac4851d5a39d6dbb17c4ac747429a7de4c8b23d9c41d141c977353f9f64f14f
6
+ metadata.gz: b4e97b6b1332f6d7bda4bdb985e27562c7f05b882643eaf63e0cb9d2f78347e853a8cfedd37f9f6f064aab3ab461019d627152fd5d415f9e6ea1c02d754dad75
7
+ data.tar.gz: f4e6a55d9145024a782f0807bce58acb9d4448aa6194fee6c0fad93aa47efddbc94fc653ac260f5ddc00db4c142758401b12a92833d43329718caf6b2fb4071d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-05-30
11
+
12
+ ### Added
13
+ - `Filter#describe_rules` returns an array of `{type:, description:}` hashes describing each configured rule for debugging and logging
14
+ - `Filter#explain(message)` runs a message through the chain without mutating stats or invoking tap blocks, returning the transformed result plus a per-rule decision trace
15
+
10
16
  ## [0.6.0] - 2026-05-29
11
17
 
12
18
  ### Added
data/README.md CHANGED
@@ -183,6 +183,44 @@ filter.apply("GET /api/users 200") # => "GET /api/users 200"
183
183
  filter.apply("worker booted") # => nil
184
184
  ```
185
185
 
186
+ ### Debugging filters with `explain`
187
+
188
+ When a filter chain isn't behaving as expected, use `#describe_rules` to dump the configuration and `#explain(message)` to trace how a specific message flows through. `#explain` is side-effect-free — it does NOT mutate `#stats` and does NOT invoke `tap_each` blocks. Sample rules are treated deterministically (a match counts as `:sampled_in`) so traces are reproducible.
189
+
190
+ ```ruby
191
+ require "philiprehberger/log_filter"
192
+
193
+ filter = Philiprehberger::LogFilter::Filter.new
194
+ .drop(/DEBUG/)
195
+ .replace(/password=\S+/, "password=[REDACTED]")
196
+ .mask_field("ssn")
197
+
198
+ filter.describe_rules
199
+ # => [
200
+ # { type: :drop_pattern, description: "drop matching /DEBUG/" },
201
+ # { type: :replace, description: "replace /password=\\S+/ with \"password=[REDACTED]\"" },
202
+ # { type: :mask_field, description: "mask field \"ssn\" with \"***\"" }
203
+ # ]
204
+
205
+ filter.explain("user login password=abc123")
206
+ # => {
207
+ # result: "user login password=[REDACTED]",
208
+ # decisions: [
209
+ # { rule: 0, type: :drop_pattern, matched: false, action: :passed },
210
+ # { rule: 1, type: :replace, matched: true, action: :replaced },
211
+ # { rule: 2, type: :mask_field, matched: false, action: :unchanged }
212
+ # ]
213
+ # }
214
+
215
+ filter.explain("DEBUG noisy line")
216
+ # => {
217
+ # result: nil,
218
+ # decisions: [
219
+ # { rule: 0, type: :drop_pattern, matched: true, action: :dropped }
220
+ # ]
221
+ # }
222
+ ```
223
+
186
224
  ### Filter Statistics
187
225
 
188
226
  ```ruby
@@ -216,6 +254,8 @@ filter.stats # => { dropped: 0, passed: 0, replaced: 0, sampled: 0 }
216
254
  | `Filter#tap_each(&block)` | Invoke the block with every message passing through; message is forwarded unchanged; returns self |
217
255
  | `Filter#apply(message)` | Run all rules; returns transformed string or nil |
218
256
  | `Filter#chain(other)` | Compose with another filter; returns a new filter piping events through both |
257
+ | `Filter#describe_rules` | Return an array of `{type:, description:}` hashes describing every rule in order |
258
+ | `Filter#explain(message)` | Trace how a message flows through the chain without mutating stats or invoking tap blocks; returns `{result:, decisions:}` |
219
259
  | `Filter#stats` | Return counters: dropped, passed, replaced, sampled |
220
260
  | `Filter#reset_stats!` | Zero all statistics counters |
221
261
  | `Wrapper.new(logger, filter)` | Wrap a Logger with a filter |
@@ -154,6 +154,55 @@ module Philiprehberger
154
154
  result
155
155
  end
156
156
 
157
+ # Return a human-readable description of every rule in the chain,
158
+ # in declaration order. Useful for debugging, logging, or rendering
159
+ # the filter configuration in an admin UI.
160
+ #
161
+ # Does not mutate any state and does not invoke any rule blocks.
162
+ #
163
+ # @return [Array<Hash{Symbol=>Object}>] one hash per rule with
164
+ # +:type+ (the rule's symbol type) and +:description+ (a
165
+ # human-readable string)
166
+ def describe_rules
167
+ @rules.map { |r| { type: r[:type], description: describe_rule(r) } }
168
+ end
169
+
170
+ # Run +message+ through the chain WITHOUT mutating stats and WITHOUT
171
+ # invoking any +tap_each+ blocks. Returns a trace describing what
172
+ # each rule did to the message, plus the final transformed value
173
+ # (or +nil+ if the chain would have dropped it).
174
+ #
175
+ # Intended for debugging filter configurations. Because +:sample+
176
+ # rules are stochastic, this method treats a sample rule as a
177
+ # deterministic +sampled_in+ when its pattern matches (it shows the
178
+ # path the message would take if the sample passed). This makes
179
+ # +explain+ deterministic and side-effect-free.
180
+ #
181
+ # @param message [String] the log message to trace
182
+ # @return [Hash] with +:result+ (the transformed message or +nil+)
183
+ # and +:decisions+ (an array of per-rule decision hashes). Each
184
+ # decision hash has +:rule+ (0-indexed integer), +:type+ (the
185
+ # rule's symbol), +:matched+ (whether the rule applied), and
186
+ # +:action+ (one of +:passed+, +:dropped+, +:replaced+, +:masked+,
187
+ # +:sampled_in+, +:sampled_out+, +:truncated+, +:tapped+,
188
+ # +:unchanged+).
189
+ def explain(message)
190
+ decisions = []
191
+ result = message.dup
192
+
193
+ @rules.each_with_index do |rule, idx|
194
+ decision, new_result = explain_rule(rule, result, idx)
195
+ decisions << decision
196
+ if decision[:action] == :dropped
197
+ return { result: nil, decisions: decisions }
198
+ end
199
+
200
+ result = new_result
201
+ end
202
+
203
+ { result: result, decisions: decisions }
204
+ end
205
+
157
206
  # Compose this filter with +other+ into a new filter.
158
207
  #
159
208
  # The returned filter's +apply+ runs +self.apply+ first and passes the
@@ -277,6 +326,102 @@ module Philiprehberger
277
326
  rescue JSON::ParserError
278
327
  nil
279
328
  end
329
+
330
+ # Render a single rule as a human-readable string.
331
+ #
332
+ # @param rule [Hash] a single rule hash
333
+ # @return [String]
334
+ def describe_rule(rule)
335
+ case rule[:type]
336
+ when :drop_pattern
337
+ "drop matching #{rule[:pattern].inspect}"
338
+ when :drop_block
339
+ 'drop if block returns truthy'
340
+ when :replace
341
+ "replace #{rule[:pattern].inspect} with #{rule[:replacement].inspect}"
342
+ when :sample
343
+ "sample #{rule[:pattern].inspect} at rate #{rule[:rate]}"
344
+ when :drop_field
345
+ "drop field #{rule[:key].inspect}"
346
+ when :mask_field
347
+ "mask field #{rule[:key].inspect} with #{rule[:mask].inspect}"
348
+ when :truncate
349
+ "truncate to #{rule[:max_length]} chars with suffix #{rule[:suffix].inspect}"
350
+ when :tap
351
+ 'tap each message (side effect)'
352
+ end
353
+ end
354
+
355
+ # Trace a single rule for {#explain}. Returns a [decision, new_message]
356
+ # pair. Never mutates stats and never calls user-supplied tap blocks.
357
+ #
358
+ # @param rule [Hash] a single rule hash
359
+ # @param message [String] the current message
360
+ # @param idx [Integer] the 0-based index of the rule in the chain
361
+ # @return [Array(Hash, String)]
362
+ def explain_rule(rule, message, idx)
363
+ case rule[:type]
364
+ when :drop_pattern
365
+ if message.match?(rule[:pattern])
366
+ [{ rule: idx, type: rule[:type], matched: true, action: :dropped }, message]
367
+ else
368
+ [{ rule: idx, type: rule[:type], matched: false, action: :passed }, message]
369
+ end
370
+ when :drop_block
371
+ if rule[:block].call(message)
372
+ [{ rule: idx, type: rule[:type], matched: true, action: :dropped }, message]
373
+ else
374
+ [{ rule: idx, type: rule[:type], matched: false, action: :passed }, message]
375
+ end
376
+ when :replace
377
+ replaced = message.gsub(rule[:pattern], rule[:replacement])
378
+ if replaced == message
379
+ [{ rule: idx, type: rule[:type], matched: false, action: :unchanged }, message]
380
+ else
381
+ [{ rule: idx, type: rule[:type], matched: true, action: :replaced }, replaced]
382
+ end
383
+ when :sample
384
+ if message.match?(rule[:pattern])
385
+ [{ rule: idx, type: rule[:type], matched: true, action: :sampled_in }, message]
386
+ else
387
+ [{ rule: idx, type: rule[:type], matched: false, action: :unchanged }, message]
388
+ end
389
+ when :drop_field
390
+ parsed = parse_json(message)
391
+ if parsed&.key?(rule[:key])
392
+ parsed.delete(rule[:key])
393
+ [{ rule: idx, type: rule[:type], matched: true, action: :replaced },
394
+ JSON.generate(parsed)]
395
+ else
396
+ [{ rule: idx, type: rule[:type], matched: false, action: :unchanged }, message]
397
+ end
398
+ when :mask_field
399
+ parsed = parse_json(message)
400
+ if parsed&.key?(rule[:key])
401
+ parsed[rule[:key]] = rule[:mask]
402
+ [{ rule: idx, type: rule[:type], matched: true, action: :masked },
403
+ JSON.generate(parsed)]
404
+ else
405
+ [{ rule: idx, type: rule[:type], matched: false, action: :unchanged }, message]
406
+ end
407
+ when :truncate
408
+ if message.length > rule[:max_length]
409
+ suffix = rule[:suffix]
410
+ max_length = rule[:max_length]
411
+ new_msg = if suffix.length >= max_length
412
+ suffix[0, max_length]
413
+ else
414
+ message[0, max_length - suffix.length] + suffix
415
+ end
416
+ [{ rule: idx, type: rule[:type], matched: true, action: :truncated }, new_msg]
417
+ else
418
+ [{ rule: idx, type: rule[:type], matched: false, action: :unchanged }, message]
419
+ end
420
+ when :tap
421
+ # Intentionally do not invoke the tap block — explain is side-effect-free.
422
+ [{ rule: idx, type: rule[:type], matched: true, action: :tapped }, message]
423
+ end
424
+ end
280
425
  end
281
426
 
282
427
  # A filter produced by {Filter#chain} that pipes events through two
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module LogFilter
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-log_filter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-30 00:00:00.000000000 Z
11
+ date: 2026-05-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Pattern-based log filtering — drop or transform log lines matching rules.
14
14
  Includes preset filters for health checks, static assets, and bot traffic.