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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +40 -0
- data/lib/philiprehberger/log_filter/filter.rb +145 -0
- data/lib/philiprehberger/log_filter/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a18a7f3be999eaccf0508fb12df28e38684d0a807e3eea4837e40ed55cc903e5
|
|
4
|
+
data.tar.gz: 4a0fd034904e1fafa47fbecf687d5ea01b9569f6d9720c96bc1f711b7e6298fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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.
|
|
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-
|
|
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.
|