gitlab-labkit 2.8.1 → 4.6.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.
@@ -9,9 +9,16 @@ module Labkit
9
9
  # @api private
10
10
  class Evaluator
11
11
  REDIS_KEY_PREFIX = "labkit:rl"
12
+ BAN_KEY_SUFFIX = ":ban"
12
13
  CHAR_VALUE_MAX_LENGTH = 200
13
14
  MISSING_VALUE_SENTINEL = "_unknown_"
14
15
 
16
+ # Carries the rule in flight out of the rule loop and into the rescue in
17
+ # #check/#peek, where the error is seen but the rule is out of scope.
18
+ # Per call, not instance state: one Evaluator serves concurrent requests.
19
+ # Only read while an exception unwinds; nil means no rule was in flight.
20
+ RuleCursor = Struct.new(:rule)
21
+
15
22
  # Atomic increment-with-TTL Lua script. The whole script runs as one
16
23
  # operation from Redis's perspective, so there is no window between
17
24
  # the increment and EXPIRE that can leak a key without TTL.
@@ -22,40 +29,86 @@ module Labkit
22
29
  # Redis treats the result as a no-op on the stored value while
23
30
  # still observing the post-state count and TTL we return.
24
31
  #
25
- # ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
32
+ # ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
26
33
  # The -1 case shouldn't arise with the atomic script, but self-healing
27
34
  # recovers keys left without TTL by any prior bug.
28
35
  INCR_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
29
36
  local ttl = ARGV[1]
30
37
  local cost = tonumber(ARGV[2])
31
- local ttl_before = redis.call('TTL', KEYS[1])
32
38
 
33
39
  local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
34
- if ttl_before < 0 then
40
+ local ttl_after = redis.call('TTL', KEYS[1])
41
+ if ttl_after < 0 then
35
42
  redis.call('EXPIRE', KEYS[1], ttl)
43
+ ttl_after = tonumber(ttl)
36
44
  end
37
45
 
38
- return {count, redis.call('TTL', KEYS[1])}
46
+ return {count, ttl_after}
39
47
  LUA
40
48
 
41
49
  # Atomic SADD + SCARD + conditional EXPIRE. SET-cardinality counterpart
42
- # of INCR_SCRIPT; same shape (read TTL, mutate, set TTL when missing,
50
+ # of INCR_SCRIPT; same shape (mutate, read TTL, set TTL when missing,
43
51
  # return post-state {count, TTL}). count is SCARD, not the SADD return.
44
52
  #
45
- # ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
53
+ # ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
46
54
  # so this also self-heals orphan keys left without TTL.
47
55
  SADD_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
48
56
  local ttl = ARGV[1]
49
57
  local member = ARGV[2]
50
- local ttl_before = redis.call('TTL', KEYS[1])
51
58
 
52
59
  redis.call('SADD', KEYS[1], member)
53
60
  local count = redis.call('SCARD', KEYS[1])
54
- if ttl_before < 0 then
61
+ local ttl_after = redis.call('TTL', KEYS[1])
62
+ if ttl_after < 0 then
55
63
  redis.call('EXPIRE', KEYS[1], ttl)
64
+ ttl_after = tonumber(ttl)
65
+ end
66
+
67
+ return {count, ttl_after}
68
+ LUA
69
+
70
+ # Atomic ban check + INCR + conditional ban write, for rules carrying
71
+ # ban_for.
72
+ #
73
+ # An active ban short-circuits before the increment, so a banned caller
74
+ # cannot extend its own window, matching Rack::Attack::Allow2Ban. This
75
+ # applies to :log rules too: a shadow that kept counting through its own
76
+ # ban would report more than enforcement would produce. The
77
+ # counter and the ban must be read and written together or a concurrent
78
+ # check can miss the threshold crossing.
79
+ #
80
+ # Returns {count, counter TTL, ban TTL}, where a ban TTL below zero means
81
+ # not banned.
82
+ BAN_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
83
+ local rule_counter_key = KEYS[1]
84
+ local ban_key = KEYS[2]
85
+
86
+ local ttl = ARGV[1]
87
+ local cost = tonumber(ARGV[2])
88
+ local limit = tonumber(ARGV[3])
89
+ local ban_for = tonumber(ARGV[4])
90
+
91
+ -- TTL answers both questions at once: >= 0 means the ban key exists
92
+ -- and tells us how long is left, so no separate EXISTS is needed.
93
+ local ban_ttl = redis.call('TTL', ban_key)
94
+ if ban_ttl >= 0 then
95
+ local existing = redis.call('GET', rule_counter_key)
96
+ return {existing or '0', redis.call('TTL', rule_counter_key), ban_ttl}
56
97
  end
57
98
 
58
- return {count, redis.call('TTL', KEYS[1])}
99
+ local count = redis.call('INCRBYFLOAT', rule_counter_key, cost)
100
+ local ttl_after = redis.call('TTL', rule_counter_key)
101
+ if ttl_after < 0 then
102
+ redis.call('EXPIRE', rule_counter_key, ttl)
103
+ ttl_after = tonumber(ttl)
104
+ end
105
+
106
+ if tonumber(count) > limit then
107
+ redis.call('SET', ban_key, '1', 'EX', ban_for)
108
+ return {count, ttl_after, ban_for}
109
+ end
110
+
111
+ return {count, ttl_after, -1}
59
112
  LUA
60
113
 
61
114
  def initialize(name:, rules:, redis:, logger:)
@@ -66,99 +119,185 @@ module Labkit
66
119
  end
67
120
 
68
121
  def check(identifier, cost: 1, rule_context: nil)
69
- check_rules(identifier, cost, rule_context)
122
+ cursor = RuleCursor.new
123
+ result = check_rules(identifier, cost, rule_context, cursor)
70
124
  rescue StandardError => e
71
125
  # Intentionally broad: fail-open applies to any unexpected error (network,
72
126
  # timeout, OOM) not only Redis protocol errors.
73
127
  report_error_metrics
74
- log_error(e, identifier)
75
- Result.new(matched: false, error: true, action: :allow)
128
+ log_error(e, identifier, cursor.rule)
129
+ result = Result.error
130
+ ensure
131
+ # StandardError-safe emission, so it cannot mask a propagating error.
132
+ # result is nil when a non-StandardError unwinds: emit nothing then.
133
+ report_check_metrics(result) if result
76
134
  end
77
135
 
78
136
  # Read-without-increment counterpart to {#check}. Same matching and Result
79
137
  # shape; the underlying Redis counter is not mutated and the TTL is not
80
138
  # extended. A missing Redis key is treated as count=0 (matched, not exceeded).
81
139
  def peek(identifier, rule_context: nil)
82
- peek_rules(identifier, rule_context)
140
+ cursor = RuleCursor.new
141
+ peek_rules(identifier, rule_context, cursor)
142
+ rescue StandardError => e
143
+ report_error_metrics
144
+ log_error(e, identifier, cursor.rule)
145
+ Result.error
146
+ end
147
+
148
+ # Deletes this limiter's counters, and any bans, for one identifier.
149
+ #
150
+ # Every rule is cleared, matched or not: a caller clearing state after a
151
+ # success knows the identifier, not which rules happened to match on the
152
+ # way in. Rules whose keys do not exist are a no-op.
153
+ #
154
+ # Fails open like {#check}: an unreachable Redis leaves the state to
155
+ # expire on its own rather than raising into the caller.
156
+ def clear(identifier)
157
+ # Counted as we go, so a connection lost mid-loop still reports the keys
158
+ # that did go rather than claiming nothing was cleared.
159
+ removed = 0
160
+
161
+ key_groups = @rules.filter_map do |rule|
162
+ next if rule.action == :skip
163
+
164
+ [build_redis_key(rule, identifier), build_redis_key(rule, identifier, BAN_KEY_SUFFIX)]
165
+ end
166
+
167
+ return 0 if key_groups.empty?
168
+
169
+ # One DEL per rule. The hash tag holds the rule name, so a rule's own
170
+ # keys share a slot but two rules do not, and Redis Cluster rejects a
171
+ # DEL spanning slots.
172
+ @redis.with do |conn|
173
+ key_groups.each { |group| removed += conn.del(*group) }
174
+ end
175
+
176
+ removed
83
177
  rescue StandardError => e
84
178
  report_error_metrics
85
- log_error(e, identifier)
86
- Result.new(matched: false, error: true, action: :allow)
179
+ log_error(e, identifier, nil)
180
+ removed
87
181
  end
88
182
 
89
183
  private
90
184
 
91
- # :log rules are non-terminating: they emit metrics and continue,
92
- # so a shadow :log rule cannot disable a following :block rule.
185
+ # Every rule that matches is evaluated and counted; matching does not
186
+ # short-circuit the loop. Exactly two cases terminate it early:
187
+ #
188
+ # - :skip terminates on match without touching Redis. No counter is
189
+ # incremented, so the branch sits before the count_distinct check
190
+ # (identifier completeness is irrelevant to a rule that builds no key).
191
+ # rule_evaluations_total still increments so the bypass stays observable.
192
+ # - a :limit rule over its limit terminates, because the request is
193
+ # rejected and later rules cannot change that. Rules declared after it
194
+ # are neither counted nor evaluated, so a blocked request debits every
195
+ # rule up to and including the one that blocked, and none after it.
196
+ # A :limit rule carrying ban_for counts as over its limit for as long
197
+ # as its ban holds, so it terminates for the whole ban. The same rule
198
+ # with action :log does identical accounting and terminates nothing,
199
+ # which is what makes it a faithful shadow.
200
+ #
201
+ # Every other matched rule - :log, and :limit while under its limit - is
202
+ # counted and collected into the returned Result, which reports the
203
+ # most-constraining evaluation (ranking lives in Result::Evaluation#<=>).
204
+ # cost is therefore debited from every matching rule, not just the first.
93
205
  #
94
- # :skip rules terminate on match without touching Redis: no counter is
95
- # incremented, so the branch sits before the count_distinct check
96
- # (identifier completeness is irrelevant to a rule that builds no key).
97
- # calls_total still increments so the bypass stays observable.
206
+ # Metrics: each evaluated rule emits rule_evaluations_total (plus the
207
+ # deprecated per-rule calls_total); the per-check checks_total is
208
+ # emitted once in #check. Full contract in the README's Metrics section.
98
209
  #
99
210
  # SET-mode rules (rule.count_distinct set) that match but whose identifier
100
- # is missing the count_distinct key fail open + log + bump errors_total, and
101
- # the loop continues to the next rule (the rule is treated as not applicable
102
- # rather than aborting the whole evaluation).
103
- def check_rules(identifier, cost, rule_context)
211
+ # is missing the count_distinct key fail open + log + bump errors_total +
212
+ # flag the Result, and the loop continues to the next rule (the rule is
213
+ # treated as not applicable rather than aborting the whole evaluation).
214
+ #
215
+ # Error handling stays whole-check (see #check): a raise part-way through
216
+ # discards the results of the rules already evaluated, even though their
217
+ # counters were incremented. No blocking verdict is lost that way - a
218
+ # :limit rule over its limit returns before any later rule can raise -
219
+ # but a rule declared after the failing one loses its chance to block.
220
+ # The request is counted and allowed. +cursor+ is set before the match, so
221
+ # a raise from the match itself (e.g. Regexp::TimeoutError) is attributed.
222
+ def check_rules(identifier, cost, rule_context, cursor)
223
+ result = Result.new
224
+
104
225
  @rules.each do |rule|
226
+ cursor.rule = rule
227
+
105
228
  next unless rule_matches?(rule, identifier)
106
229
 
107
230
  if rule.action == :skip
108
231
  report_skipped_metrics(rule)
109
- return skip_result(rule)
232
+ return result.skip!(rule)
110
233
  end
111
234
 
112
235
  if rule.count_distinct && missing_count_distinct_value?(rule, identifier)
113
236
  log_missing_count_distinct(rule, identifier)
114
237
  report_error_metrics
238
+ result.degraded!
115
239
  next
116
240
  end
117
241
 
118
- result = evaluate_rule(rule, identifier, cost, rule_context)
119
- report_matched_metrics(result)
120
- return result unless rule.action == :log
242
+ evaluation = evaluate_rule(rule, identifier, cost, rule_context)
243
+ result.add_evaluation(evaluation)
244
+ report_evaluation_metrics(evaluation)
245
+ return result if result.block?
121
246
  end
122
247
 
123
- report_unmatched_metrics
124
- Result.new(matched: false, action: :allow)
248
+ # The loop is done, so anything raised from here on belongs to no rule.
249
+ cursor.rule = nil
250
+
251
+ report_unmatched_metrics unless result.matched?
252
+ result
253
+ rescue StandardError => e # binds e for the ensure
254
+ raise
255
+ ensure
256
+ # Keep the rule attributed while an exception unwinds; clear it on
257
+ # every normal exit (early returns included).
258
+ cursor.rule = nil unless e
125
259
  end
126
260
 
127
- # Mirror of check_rules without metrics: peek skips :log rules (their state
128
- # is unobservable through peek).
261
+ # Mirror of check_rules without metrics or writes. :log rules are read
262
+ # here too: check can report a :log rule's evaluation, so excluding them
263
+ # would make peek answer a different question than check.
129
264
  #
130
265
  # peek does not need the count_distinct identifier key - it reads SCARD on
131
266
  # the rule-keyed compound key, which contains the cardinality across all
132
267
  # members. So missing-key fail-open does not apply here.
133
- def peek_rules(identifier, rule_context)
268
+ def peek_rules(identifier, rule_context, cursor)
269
+ result = Result.new
270
+
134
271
  @rules.each do |rule|
135
- next if rule.action == :log
272
+ cursor.rule = rule
273
+
136
274
  next unless rule_matches?(rule, identifier)
137
275
 
138
- return skip_result(rule) if rule.action == :skip
276
+ return result.skip!(rule) if rule.action == :skip
139
277
 
140
- return peek_rule(rule, identifier, rule_context)
278
+ result.add_evaluation(peek_rule(rule, identifier, rule_context))
279
+ return result if result.block?
141
280
  end
142
281
 
143
- Result.new(matched: false, action: :allow)
282
+ result
283
+ rescue StandardError => e # binds e for the ensure
284
+ raise
285
+ ensure
286
+ cursor.rule = nil unless e
144
287
  end
145
288
 
146
289
  def rule_matches?(rule, identifier)
147
290
  rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
148
291
  end
149
292
 
150
- # A matched :skip rule allows without evaluating: no counter exists, so
151
- # info is nil (to_response_headers is {} for skip results).
152
- def skip_result(rule)
153
- Result.new(matched: true, action: :allow, rule: rule)
154
- end
155
-
156
293
  def missing_count_distinct_value?(rule, identifier)
157
294
  value = identifier[rule.count_distinct]
158
295
  value.nil? || value.to_s.empty?
159
296
  end
160
297
 
161
298
  def evaluate_rule(rule, identifier, cost, rule_context)
299
+ return evaluate_ban_rule(rule, identifier, cost, rule_context) if rule.ban_for
300
+
162
301
  redis_key = build_redis_key(rule, identifier)
163
302
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
164
303
  resolved_period = Integer(resolve_value(rule.period, rule_context))
@@ -172,21 +311,78 @@ module Labkit
172
311
  incr_with_ttl(redis_key, resolved_period, cost)
173
312
  end
174
313
 
175
- build_result(rule, resolved_limit, resolved_period, count, ttl)
314
+ build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
176
315
  end
177
316
 
178
317
  def peek_rule(rule, identifier, rule_context)
318
+ return peek_ban_rule(rule, identifier, rule_context) if rule.ban_for
319
+
179
320
  redis_key = build_redis_key(rule, identifier)
180
321
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
181
322
  resolved_period = Integer(resolve_value(rule.period, rule_context))
182
323
 
183
324
  count, ttl = rule.count_distinct ? scard_with_ttl(redis_key) : read_with_ttl(redis_key)
184
- build_result(rule, resolved_limit, resolved_period, count, ttl)
325
+ build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
326
+ end
327
+
328
+ def evaluate_ban_rule(rule, identifier, cost, rule_context)
329
+ resolved_limit = Integer(resolve_value(rule.limit, rule_context))
330
+ resolved_period = Integer(resolve_value(rule.period, rule_context))
331
+ resolved_ban_for = resolve_ban_for(rule, rule_context)
332
+
333
+ count, ttl, ban_ttl = ban_incr_with_ttl(
334
+ build_redis_key(rule, identifier),
335
+ build_redis_key(rule, identifier, BAN_KEY_SUFFIX),
336
+ resolved_period, cost, resolved_limit, resolved_ban_for
337
+ )
338
+
339
+ build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
340
+ end
341
+
342
+ # A callable ban_for is only checked at construction, so it can still hand
343
+ # back something Redis would reject on the SET that writes the ban. Failing
344
+ # here names ban_for rather than surfacing a line number from the script.
345
+ def resolve_ban_for(rule, rule_context)
346
+ raw = resolve_value(rule.ban_for, rule_context)
347
+ seconds = Integer(raw, exception: false)
348
+ raise ArgumentError, "ban_for resolved to #{raw.inspect}, need at least 1 second" if seconds.nil? || seconds < 1
349
+
350
+ seconds
351
+ end
352
+
353
+ def peek_ban_rule(rule, identifier, rule_context)
354
+ resolved_limit = Integer(resolve_value(rule.limit, rule_context))
355
+ resolved_period = Integer(resolve_value(rule.period, rule_context))
356
+
357
+ count, ttl, ban_ttl = read_ban_with_ttl(
358
+ build_redis_key(rule, identifier),
359
+ build_redis_key(rule, identifier, BAN_KEY_SUFFIX)
360
+ )
361
+
362
+ build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
363
+ end
364
+
365
+ # A ban blocks whether it was written by this call or an earlier one, so
366
+ # +exceeded+ tracks the ban rather than the count: once the counter window
367
+ # has expired the count can sit below the limit while the ban still holds.
368
+ # reset_at reports when the caller may retry, which is the ban expiry.
369
+ def build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
370
+ banned = ban_ttl >= 0
371
+ window_remaining = ttl >= 0 ? ttl : resolved_period
372
+
373
+ info = Result::Info.new(
374
+ resolved_limit: resolved_limit, resolved_period: resolved_period,
375
+ count: count,
376
+ # A ban outlives its counter, so count reads 0 once the window has
377
+ # gone. Nothing is remaining while the ban still blocks.
378
+ remaining: banned ? 0 : [resolved_limit - count, 0].max,
379
+ reset_at: Time.now.utc + (banned ? ban_ttl : window_remaining)
380
+ )
381
+
382
+ Result::Evaluation.new(rule: rule, exceeded: banned, info: info)
185
383
  end
186
384
 
187
- def build_result(rule, resolved_limit, resolved_period, count, ttl)
188
- exceeded = count > resolved_limit
189
- action = exceeded ? rule.action : :allow
385
+ def build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
190
386
  info = Result::Info.new(
191
387
  resolved_limit: resolved_limit, resolved_period: resolved_period,
192
388
  count: count,
@@ -194,15 +390,22 @@ module Labkit
194
390
  reset_at: Time.now.utc + (ttl >= 0 ? ttl : resolved_period)
195
391
  )
196
392
 
197
- Result.new(matched: true, exceeded: exceeded, action: action, rule: rule, info: info)
393
+ Result::Evaluation.new(rule: rule, exceeded: count > resolved_limit, info: info)
198
394
  end
199
395
 
200
- def build_redis_key(rule, identifier)
201
- key = "#{REDIS_KEY_PREFIX}:#{@name}:#{rule.name}"
396
+ # The limiter, rule and characteristics sit inside a Redis hash tag, so a
397
+ # counter and its ban always hash to the same cluster slot. BAN_SCRIPT
398
+ # touches both in one call, and Redis Cluster rejects a script whose keys
399
+ # span slots. +suffix+ is appended outside the tag, so it cannot change
400
+ # the slot and cannot collide with a characteristic value.
401
+ def build_redis_key(rule, identifier, suffix = nil)
402
+ key = "#{REDIS_KEY_PREFIX}:{#{@name}:#{rule.name}"
202
403
  rule.characteristics.each do |char|
203
404
  value = resolve_char_value(char, identifier)
204
- key += ":#{char}:#{encode_char_value(value)}"
405
+ key << ":#{char}:#{encode_char_value(value)}"
205
406
  end
407
+ key << "}"
408
+ key << suffix if suffix
206
409
  key
207
410
  end
208
411
 
@@ -247,7 +450,7 @@ module Labkit
247
450
  end
248
451
 
249
452
  # Atomically increments the counter by `cost`, sets the TTL on first
250
- # write, and reads back the post-increment TTL, all in one Redis
453
+ # write, and returns the window's remaining TTL, all in one Redis
251
454
  # operation via Lua. See INCR_SCRIPT for the script body.
252
455
  #
253
456
  # count is parsed as Float because INCRBYFLOAT returns a string-encoded
@@ -260,9 +463,33 @@ module Labkit
260
463
  end
261
464
  end
262
465
 
466
+ # Atomic ban check + increment + conditional ban write. See BAN_SCRIPT.
467
+ # The returned ban TTL is negative when no ban is in force.
468
+ def ban_incr_with_ttl(counter_key, ban_key, period, cost, limit, ban_for)
469
+ @redis.with do |conn|
470
+ raw_count, ttl, ban_ttl = BAN_SCRIPT.eval(
471
+ conn, keys: [counter_key, ban_key], argv: [period, cost, limit, ban_for]
472
+ )
473
+ [Float(raw_count), Integer(ttl), Integer(ban_ttl)]
474
+ end
475
+ end
476
+
477
+ # Pipelined read of both keys for #peek. No EXPIRE and no SET: peeking a
478
+ # banned identifier must not extend either the window or the ban.
479
+ def read_ban_with_ttl(counter_key, ban_key)
480
+ @redis.with do |conn|
481
+ raw_count, ttl, ban_ttl = conn.pipelined do |pipe|
482
+ pipe.get(counter_key)
483
+ pipe.ttl(counter_key)
484
+ pipe.ttl(ban_key)
485
+ end
486
+ [raw_count.nil? ? 0.0 : Float(raw_count), Integer(ttl), Integer(ban_ttl)]
487
+ end
488
+ end
489
+
263
490
  # Pipelined GET + TTL. No EXPIRE: peek must not extend the window.
264
491
  # A missing key (GET => nil, TTL => -2) is reported as count=0; the
265
- # build_result fallback then derives reset_at from the rule period
492
+ # build_evaluation fallback then derives reset_at from the rule period
266
493
  # since there is no Redis-side window to read.
267
494
  #
268
495
  # Float parsing accepts both INCR-stored ("5") and INCRBYFLOAT-stored
@@ -303,14 +530,22 @@ module Labkit
303
530
  end
304
531
  end
305
532
 
306
- def log_error(error, identifier)
533
+ # rule is nil when the error was raised with no rule in flight - before the
534
+ # loop reached one, or after it finished - so the field is logged as null
535
+ # rather than omitted, the same way identifier is. A named rule is the rule
536
+ # whose match or evaluation raised.
537
+ # Never raises: a logging failure must not break fail-open.
538
+ def log_error(error, identifier, rule = nil)
307
539
  @logger.warn(
308
540
  name: @name,
541
+ rule: rule&.name,
309
542
  Labkit::Fields::ERROR_TYPE => "rate_limit_error",
310
543
  Labkit::Fields::CLASS_NAME => error.class.to_s,
311
544
  Labkit::Fields::ERROR_MESSAGE => error.message,
312
545
  identifier: identifier&.to_h
313
546
  )
547
+ rescue StandardError
548
+ nil
314
549
  end
315
550
 
316
551
  def log_missing_count_distinct(rule, identifier)
@@ -323,43 +558,67 @@ module Labkit
323
558
  )
324
559
  end
325
560
 
326
- def report_matched_metrics(result)
327
- Metrics.calls_total.increment(
328
- rate_limiter: @name,
329
- rule: result.rule.name,
330
- action: result.action.to_s
331
- )
332
- Metrics.limit_gauge.set(
333
- { rate_limiter: @name, rule: result.rule.name },
334
- result.info.resolved_limit
561
+ def report_evaluation_metrics(evaluation)
562
+ rule_labels = { rate_limiter: @name, rule: evaluation.rule.name }
563
+
564
+ Metrics.safe_increment(
565
+ :rule_evaluations_total,
566
+ rule_labels.merge(action: evaluation.rule.action.to_s, result: evaluation_result(evaluation)),
567
+ logger: @logger
335
568
  )
336
- Metrics.period_gauge.set(
337
- { rate_limiter: @name, rule: result.rule.name },
338
- result.info.resolved_period
569
+ # Deprecated dual emission - remove together with Metrics.calls_total.
570
+ Metrics.safe_increment(
571
+ :calls_total,
572
+ rule_labels.merge(action: (evaluation.exceeded? ? evaluation.rule.action : :allow).to_s),
573
+ logger: @logger
339
574
  )
575
+ Metrics.safe_set(:limit_gauge, rule_labels, evaluation.info.resolved_limit, logger: @logger)
576
+ Metrics.safe_set(:period_gauge, rule_labels, evaluation.info.resolved_period, logger: @logger)
577
+ end
578
+
579
+ # An exceeded :log rule reports "log" rather than the "allow" the caller
580
+ # sees, so shadow rules over their limit stay visible.
581
+ # A ban is not a distinct action, so without this a blocking ban would be
582
+ # indistinguishable from an ordinary block. On a ban_for rule exceeded?
583
+ # means the ban is in force, whether this call wrote it or an earlier one
584
+ # did, which is exactly what is worth counting separately.
585
+ def evaluation_result(evaluation)
586
+ return "allow" unless evaluation.exceeded?
587
+ return "banned" if evaluation.rule.ban_for
588
+
589
+ evaluation.rule.action == :limit ? "block" : "log"
340
590
  end
341
591
 
342
- # calls_total carries action="skip" (the rule action, not the :allow the
343
- # caller sees) so bypass traffic stays distinguishable from counted
344
- # allows. No limit/period gauges: a skip rule has no limit to report.
345
592
  def report_skipped_metrics(rule)
346
- Metrics.calls_total.increment(
347
- rate_limiter: @name,
348
- rule: rule.name,
349
- action: "skip"
593
+ Metrics.safe_increment(
594
+ :rule_evaluations_total,
595
+ { rate_limiter: @name, rule: rule.name, action: "skip", result: "skip" },
596
+ logger: @logger
350
597
  )
598
+ # Deprecated dual emission - remove together with Metrics.calls_total.
599
+ Metrics.safe_increment(:calls_total, { rate_limiter: @name, rule: rule.name, action: "skip" }, logger: @logger)
351
600
  end
352
601
 
602
+ # Deprecated dual emission - remove together with Metrics.calls_total.
353
603
  def report_unmatched_metrics
354
- Metrics.calls_total.increment(
355
- rate_limiter: @name,
356
- rule: "unmatched",
357
- action: "allow"
604
+ Metrics.safe_increment(:calls_total, { rate_limiter: @name, rule: "unmatched", action: "allow" }, logger: @logger)
605
+ end
606
+
607
+ def report_check_metrics(result)
608
+ Metrics.safe_increment(
609
+ :checks_total,
610
+ {
611
+ rate_limiter: @name,
612
+ action: result.action.to_s,
613
+ matched: result.matched?.to_s,
614
+ error: (result.error? || result.degraded?).to_s
615
+ },
616
+ logger: @logger
358
617
  )
359
618
  end
360
619
 
361
620
  def report_error_metrics
362
- Metrics.errors_total.increment(rate_limiter: @name)
621
+ Metrics.safe_increment(:errors_total, { rate_limiter: @name }, logger: @logger)
363
622
  end
364
623
  end
365
624
  end
@@ -6,6 +6,9 @@ module Labkit
6
6
  # describe the caller (e.g. user, ip, endpoint).
7
7
  # Endpoint values are normalised at construction time (query string stripped).
8
8
  class Identifier
9
+ InvalidKeyError = Class.new(ArgumentError)
10
+ DuplicateNormalizedKeyError = Class.new(InvalidKeyError)
11
+
9
12
  # Normalize an endpoint value: strip query string.
10
13
  def self.normalize_endpoint(value)
11
14
  return value unless value.is_a?(String)
@@ -16,7 +19,29 @@ module Labkit
16
19
  attr_reader :attributes
17
20
 
18
21
  def initialize(attributes = {})
19
- normalised = attributes.transform_keys(&:to_sym)
22
+ normalised = {}
23
+ original_keys = {}
24
+
25
+ attributes.each do |key, value|
26
+ unless key.respond_to?(:to_sym)
27
+ # Reject keys such as nil, 42, and [] instead of leaking NoMethodError;
28
+ # identifiers must have a canonical symbol key for matching and serialization.
29
+ raise InvalidKeyError, "Identifier key #{key.inspect} must respond to #to_sym"
30
+ end
31
+
32
+ normalised_key = key.to_sym
33
+ if normalised.key?(normalised_key)
34
+ # Reject { user: 1, "user" => 2 } (and its reverse order): silently
35
+ # choosing a value would make the rate-limit bucket insertion-order dependent.
36
+ raise DuplicateNormalizedKeyError,
37
+ "Identifier keys normalize to the same key #{normalised_key.inspect}: " \
38
+ "#{original_keys[normalised_key].inspect} and #{key.inspect}"
39
+ end
40
+
41
+ original_keys[normalised_key] = key
42
+ normalised[normalised_key] = value
43
+ end
44
+
20
45
  normalised[:endpoint] = self.class.normalize_endpoint(normalised[:endpoint]) if normalised.key?(:endpoint)
21
46
  @attributes = normalised.freeze
22
47
  end