philiprehberger-log_filter 0.1.7 → 0.2.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: 20449cdb409bc84ab640e2baa8e3762c3695eed5dc5e4c740c70a15acb249675
4
- data.tar.gz: bda4b072fd035940d86ad1cee170aa9683fe642546d166fe64164c99dd72d32a
3
+ metadata.gz: 683ed9a496a285367a229889b099e3fe5dcaacce4c4f659d98f51c1f151f77f6
4
+ data.tar.gz: 97ad3d5ecb23b237128cbe1f416c444bf531e428d6f5611aeb1ca3f606fa7d16
5
5
  SHA512:
6
- metadata.gz: 03d7c21e00e2baeee8c6ccae4adea63c4fa58a78a7a2d6e6d5a802190014d630f094a5d602e0023979a5af2afcd1fcf395678185f4232cd478d67817a6990c79
7
- data.tar.gz: 822fd979279c229e1950ff35d8c195a2953eb4eba1b5aeaf73254914c63fd5c840086ff6361a15dc844cedf604140ed5534a38d62f9a5a7b28a5c009bb5bc499
6
+ metadata.gz: a8fc6d49639fc0b38e7915953f19d41d8aec7f0e6eeae4eff394cbc55ebed7331ef88a3f0da856d8eddeac762a20d1e24c0de84ac29003bdb621a49ad893de38
7
+ data.tar.gz: df60ffb387bbb13b15fe9cb903d46494bcc33800822a5e42dc73e5b01275943ca0840a0b2d5bf93355e66d411995b129a9f17ff3ff79874e4e346fa0920bb248
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-29
11
+
12
+ ### Added
13
+
14
+ - Sampling support via `Filter#sample(pattern, rate:)` — pass through only a fraction of matching messages
15
+ - Filter statistics via `Filter#stats` and `Filter#reset_stats!` — thread-safe atomic counters for dropped, passed, replaced, and sampled messages
16
+ - Structured log support via `Filter#drop_field(key)` and `Filter#mask_field(key, with:)` — parse JSON messages, remove or mask fields, re-serialize
17
+
10
18
  ## [0.1.7] - 2026-03-26
11
19
 
12
20
  ### Changed
data/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-log-filter/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-log-filter/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-log_filter.svg)](https://rubygems.org/gems/philiprehberger-log_filter)
5
+ [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-log-filter)](https://github.com/philiprehberger/rb-log-filter/releases)
6
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-log-filter)](https://github.com/philiprehberger/rb-log-filter/commits/main)
5
7
  [![License](https://img.shields.io/github/license/philiprehberger/rb-log-filter)](LICENSE)
8
+ [![Bug Reports](https://img.shields.io/github/issues/philiprehberger/rb-log-filter/bug)](https://github.com/philiprehberger/rb-log-filter/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
9
+ [![Feature Requests](https://img.shields.io/github/issues/philiprehberger/rb-log-filter/enhancement)](https://github.com/philiprehberger/rb-log-filter/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
6
10
  [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
7
11
 
8
12
  Pattern-based log filtering with drop, replace, and preset rules
@@ -63,6 +67,8 @@ filtered_logger.info("GET /api/users 200") # logs normally
63
67
  ### Using Presets
64
68
 
65
69
  ```ruby
70
+ require "philiprehberger/log_filter"
71
+
66
72
  # Drop health-check noise
67
73
  filter = Philiprehberger::LogFilter.health_check_filter
68
74
  filtered_logger = Philiprehberger::LogFilter.wrap(logger, filter)
@@ -77,11 +83,60 @@ filter = Philiprehberger::LogFilter.bot_filter
77
83
  ### Block-Based Drop Rules
78
84
 
79
85
  ```ruby
86
+ require "philiprehberger/log_filter"
87
+
80
88
  filter = Philiprehberger::LogFilter::Filter.new
81
89
  .drop_if { |msg| msg.length > 1000 } # drop excessively long messages
82
90
  .drop_if { |msg| msg.count("\n") > 10 } # drop multi-line spam
83
91
  ```
84
92
 
93
+ ### Sampling
94
+
95
+ ```ruby
96
+ require "philiprehberger/log_filter"
97
+
98
+ # Only pass through 10% of debug messages
99
+ filter = Philiprehberger::LogFilter::Filter.new
100
+ .sample(/DEBUG/, rate: 0.1)
101
+
102
+ filter.apply("DEBUG verbose output") # => nil (90% of the time)
103
+ filter.apply("INFO normal message") # => "INFO normal message" (always passes)
104
+ ```
105
+
106
+ ### Structured Log Support
107
+
108
+ ```ruby
109
+ require "philiprehberger/log_filter"
110
+
111
+ filter = Philiprehberger::LogFilter::Filter.new
112
+ .drop_field("password")
113
+ .mask_field("ssn", with: "***")
114
+
115
+ filter.apply('{"user":"alice","password":"secret","ssn":"123-45-6789"}')
116
+ # => '{"user":"alice","ssn":"***"}'
117
+
118
+ # Non-JSON messages pass through unmodified
119
+ filter.apply("plain text log line") # => "plain text log line"
120
+ ```
121
+
122
+ ### Filter Statistics
123
+
124
+ ```ruby
125
+ require "philiprehberger/log_filter"
126
+
127
+ filter = Philiprehberger::LogFilter::Filter.new
128
+ .drop(/DEBUG/)
129
+ .replace(/secret/, "[REDACTED]")
130
+
131
+ filter.apply("DEBUG noise")
132
+ filter.apply("has secret data")
133
+ filter.apply("normal message")
134
+
135
+ filter.stats # => { dropped: 1, passed: 2, replaced: 1, sampled: 0 }
136
+ filter.reset_stats!
137
+ filter.stats # => { dropped: 0, passed: 0, replaced: 0, sampled: 0 }
138
+ ```
139
+
85
140
  ## API
86
141
 
87
142
  | Class / Method | Description |
@@ -90,7 +145,12 @@ filter = Philiprehberger::LogFilter::Filter.new
90
145
  | `Filter#drop(pattern)` | Add a regex drop rule; returns self |
91
146
  | `Filter#drop_if(&block)` | Add a block-based drop rule; returns self |
92
147
  | `Filter#replace(pattern, replacement)` | Add a replacement rule; returns self |
148
+ | `Filter#sample(pattern, rate:)` | Add a sampling rule; only pass rate fraction of matches |
149
+ | `Filter#drop_field(key)` | Remove a field from JSON log messages; returns self |
150
+ | `Filter#mask_field(key, with:)` | Mask a field value in JSON log messages; returns self |
93
151
  | `Filter#apply(message)` | Run all rules; returns transformed string or nil |
152
+ | `Filter#stats` | Return counters: dropped, passed, replaced, sampled |
153
+ | `Filter#reset_stats!` | Zero all statistics counters |
94
154
  | `Wrapper.new(logger, filter)` | Wrap a Logger with a filter |
95
155
  | `Presets.health_check` | Filter dropping health-check paths |
96
156
  | `Presets.assets` | Filter dropping static-asset requests |
@@ -108,6 +168,13 @@ bundle exec rspec
108
168
  bundle exec rubocop
109
169
  ```
110
170
 
171
+ ## Support
172
+
173
+ If you find this package useful, consider giving it a star on GitHub — it helps motivate continued maintenance and development.
174
+
175
+ [![LinkedIn](https://img.shields.io/badge/Philip%20Rehberger-LinkedIn-0A66C2?logo=linkedin)](https://www.linkedin.com/in/philiprehberger)
176
+ [![More packages](https://img.shields.io/badge/more-open%20source%20packages-blue)](https://philiprehberger.com/open-source-packages)
177
+
111
178
  ## License
112
179
 
113
180
  [MIT](LICENSE)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'securerandom'
5
+
3
6
  module Philiprehberger
4
7
  module LogFilter
5
8
  # Chain of rules that can drop or transform log messages.
@@ -13,6 +16,8 @@ module Philiprehberger
13
16
 
14
17
  def initialize
15
18
  @rules = []
19
+ @mutex = Mutex.new
20
+ @stats = { dropped: 0, passed: 0, replaced: 0, sampled: 0 }
16
21
  end
17
22
 
18
23
  # Add a pattern-based drop rule. Messages matching +pattern+ are suppressed.
@@ -47,6 +52,56 @@ module Philiprehberger
47
52
  self
48
53
  end
49
54
 
55
+ # Add a sampling rule. Only pass through +rate+ fraction of messages
56
+ # matching +pattern+. Non-matching messages pass through unaffected.
57
+ #
58
+ # @param pattern [Regexp] the pattern to match
59
+ # @param rate [Float] sampling rate between 0.0 and 1.0
60
+ # @return [self] for chaining
61
+ def sample(pattern, rate:)
62
+ raise ArgumentError, 'rate must be between 0.0 and 1.0' unless rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
63
+
64
+ @rules << { type: :sample, pattern: pattern, rate: rate.to_f }
65
+ self
66
+ end
67
+
68
+ # Add a rule to remove a field from JSON log messages.
69
+ # Non-JSON messages pass through unmodified.
70
+ #
71
+ # @param key [String] the JSON field key to remove
72
+ # @return [self] for chaining
73
+ def drop_field(key)
74
+ @rules << { type: :drop_field, key: key.to_s }
75
+ self
76
+ end
77
+
78
+ # Add a rule to mask a field value in JSON log messages.
79
+ # Non-JSON messages pass through unmodified.
80
+ #
81
+ # @param key [String] the JSON field key to mask
82
+ # @param with [String] the mask replacement value
83
+ # @return [self] for chaining
84
+ def mask_field(key, with: '***')
85
+ @rules << { type: :mask_field, key: key.to_s, mask: with }
86
+ self
87
+ end
88
+
89
+ # Return current filter statistics.
90
+ #
91
+ # @return [Hash] counters for :dropped, :passed, :replaced, :sampled
92
+ def stats
93
+ @mutex.synchronize { @stats.dup }
94
+ end
95
+
96
+ # Reset all statistics counters to zero.
97
+ #
98
+ # @return [void]
99
+ def reset_stats!
100
+ @mutex.synchronize do
101
+ @stats = { dropped: 0, passed: 0, replaced: 0, sampled: 0 }
102
+ end
103
+ end
104
+
50
105
  # Run all rules against +message+ in order.
51
106
  #
52
107
  # @param message [String] the log message to filter
@@ -56,23 +111,98 @@ module Philiprehberger
56
111
 
57
112
  @rules.each do |rule|
58
113
  result = apply_rule(rule, result)
59
- return nil if result.nil?
114
+ if result.nil?
115
+ increment_stat(:dropped)
116
+ return nil
117
+ end
60
118
  end
61
119
 
120
+ increment_stat(:passed)
62
121
  result
63
122
  end
64
123
 
65
124
  private
66
125
 
126
+ # @param stat [Symbol] the stat key to increment
127
+ # @return [void]
128
+ def increment_stat(stat)
129
+ @mutex.synchronize { @stats[stat] += 1 }
130
+ end
131
+
67
132
  # @param rule [Hash] a single rule hash
68
133
  # @param message [String] the current message
69
134
  # @return [String, nil]
70
135
  def apply_rule(rule, message)
71
136
  case rule[:type]
72
- when :drop_pattern then message.match?(rule[:pattern]) ? nil : message
73
- when :drop_block then rule[:block].call(message) ? nil : message
74
- when :replace then message.gsub(rule[:pattern], rule[:replacement])
137
+ when :drop_pattern
138
+ message.match?(rule[:pattern]) ? nil : message
139
+ when :drop_block
140
+ rule[:block].call(message) ? nil : message
141
+ when :replace
142
+ replaced = message.gsub(rule[:pattern], rule[:replacement])
143
+ if replaced != message
144
+ increment_stat(:replaced)
145
+ end
146
+ replaced
147
+ when :sample
148
+ apply_sample_rule(rule, message)
149
+ when :drop_field
150
+ apply_drop_field_rule(rule, message)
151
+ when :mask_field
152
+ apply_mask_field_rule(rule, message)
153
+ end
154
+ end
155
+
156
+ # @param rule [Hash] a sample rule
157
+ # @param message [String] the current message
158
+ # @return [String, nil]
159
+ def apply_sample_rule(rule, message)
160
+ return message unless message.match?(rule[:pattern])
161
+
162
+ return unless SecureRandom.rand < rule[:rate]
163
+
164
+ increment_stat(:sampled)
165
+ message
166
+ end
167
+
168
+ # @param rule [Hash] a drop_field rule
169
+ # @param message [String] the current message
170
+ # @return [String]
171
+ def apply_drop_field_rule(rule, message)
172
+ parsed = parse_json(message)
173
+ return message unless parsed
174
+
175
+ parsed.delete(rule[:key])
176
+ JSON.generate(parsed)
177
+ rescue StandardError
178
+ message
179
+ end
180
+
181
+ # @param rule [Hash] a mask_field rule
182
+ # @param message [String] the current message
183
+ # @return [String]
184
+ def apply_mask_field_rule(rule, message)
185
+ parsed = parse_json(message)
186
+ return message unless parsed
187
+
188
+ if parsed.key?(rule[:key])
189
+ parsed[rule[:key]] = rule[:mask]
190
+ increment_stat(:replaced)
75
191
  end
192
+ JSON.generate(parsed)
193
+ rescue StandardError
194
+ message
195
+ end
196
+
197
+ # Attempt to parse a string as JSON.
198
+ #
199
+ # @param str [String] the string to parse
200
+ # @return [Hash, nil] parsed hash or nil if not valid JSON object
201
+ def parse_json(str)
202
+ result = JSON.parse(str)
203
+ result.is_a?(Hash) ? result : nil
204
+ rescue JSON::ParserError
205
+ nil
76
206
  end
77
207
  end
78
208
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module LogFilter
5
- VERSION = "0.1.7"
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "log_filter/version"
4
- require_relative "log_filter/filter"
5
- require_relative "log_filter/wrapper"
6
- require_relative "log_filter/presets"
3
+ require_relative 'log_filter/version'
4
+ require_relative 'log_filter/filter'
5
+ require_relative 'log_filter/wrapper'
6
+ require_relative 'log_filter/presets'
7
7
 
8
8
  module Philiprehberger
9
9
  module LogFilter
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.1.7
4
+ version: 0.2.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-03-27 00:00:00.000000000 Z
11
+ date: 2026-03-30 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.