gitlab-labkit 5.7.0 → 5.8.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: f2d9d587afe49d99a2ada5a8757230d69024a1085009f1e65c29ac97aa005e98
4
- data.tar.gz: 35e55c1e1f9aef6836736e6917cffe4813aa37655eebf5534893f9b7b232fb89
3
+ metadata.gz: bbffb0883d80c949bfb28ffec2943b9cc89ffb88c2c02656afe99ad777e0f152
4
+ data.tar.gz: 93610eb46b3dbbfebe40c1b97d31d07b69af3f5e5ee0f152be29a66eca8da03e
5
5
  SHA512:
6
- metadata.gz: 83a9378e749aa4c04cf05cca872fffabe8945802b169c996aba579b43b73e582d98dca6c37b31c82098fe3edda41932ddc81813010ab278e2df7569812c7926e
7
- data.tar.gz: 5b00ebd19184fbfaec05c1cdf40de93b74a70c0a7aa2b167213425debada2ea6708b61874d4ad23a9de40c950724b47da599d37daf0116d323ed8b4db76a6d23
6
+ metadata.gz: c01235e9277e28adbde6cb2e14cedebbabe78b46a8f7e771664875da94cd59ea3c31e2033b152ea8e23dce3cd8ce84c0c5fd310c9691d1662006df0e87f66b67
7
+ data.tar.gz: 87c05f1a0543e7969b35023d227d32cabc87455caa99aeca54a83832a5e40a2968895afb3982c4ecfcadc31d891558ce4abccb249f0758f95903367fd56b82df
data/.copier-answers.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.87.1
6
+ _commit: v1.89.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  gitlab_namespace: gitlab-org/ruby/gems
data/AGENTS.md CHANGED
@@ -1,5 +1,37 @@
1
- <<<<<<< before updating
2
- # AGENTS.md
1
+ # Agent guidelines
2
+
3
+ This project uses [Conventional Commits](https://www.conventionalcommits.org/).
4
+ Commit messages drive automated [semantic releases](https://semantic-release.gitbook.io/)
5
+ (see `.releaserc.json`), so use `feat:`, `fix:`, etc. prefixes to ensure the
6
+ correct version bump and changelog entry.
7
+
8
+ ## AI-authored GitLab comments
9
+
10
+ Wrap the full body of any comment, note, or reply you post to GitLab (issues,
11
+ merge requests, epics — via `glab`/`glab api`) in `<:robot:>` / `</:robot:>`
12
+ tags (GitLab emoji shortcode, renders as 🤖), so readers can tell at a glance
13
+ that it's AI-generated:
14
+
15
+ ```
16
+ <:robot:>
17
+
18
+ ...full comment body...
19
+
20
+ </:robot:>
21
+ ```
22
+
23
+ This applies to newly posted comments. It does not apply to code, commit
24
+ messages, or MR/issue descriptions unless asked.
25
+
26
+ <!--
27
+ ####################################################################
28
+ # 🌊✨ PLEASE ADD PROJECT SPECIFIC RULES BELOW THIS LINE ✨🌊 #
29
+ # The section above is managed by the common-template-copier #
30
+ # template and is updated automatically on `copier update`. #
31
+ # Add your project-specific instructions below this line so #
32
+ # they are preserved when the template is updated. #
33
+ ####################################################################
34
+ -->
3
35
 
4
36
  Instructions for AI agents and automated tooling working in this repository.
5
37
 
@@ -135,39 +167,3 @@ When a change does not fit one layer clearly, say so in the MR instead of pickin
135
167
  Mention the sync agent on the review thread to have it draft the scenario MR; if it cannot, open a labkit-spec issue and link it from the thread.
136
168
  - Never edit Lua scripts, key construction, header names, metric names, or log field names to fix a Ruby-only bug.
137
169
  If a fix seems to need that, it is a contract change.
138
- =======
139
- # Agent guidelines
140
-
141
- This project uses [Conventional Commits](https://www.conventionalcommits.org/).
142
- Commit messages drive automated [semantic releases](https://semantic-release.gitbook.io/)
143
- (see `.releaserc.json`), so use `feat:`, `fix:`, etc. prefixes to ensure the
144
- correct version bump and changelog entry.
145
-
146
- ## AI-authored GitLab comments
147
-
148
- Wrap the full body of any comment, note, or reply you post to GitLab (issues,
149
- merge requests, epics — via `glab`/`glab api`) in `<:robot:>` / `</:robot:>`
150
- tags (GitLab emoji shortcode, renders as 🤖), so readers can tell at a glance
151
- that it's AI-generated:
152
-
153
- ```
154
- <:robot:>
155
-
156
- ...full comment body...
157
-
158
- </:robot:>
159
- ```
160
-
161
- This applies to newly posted comments. It does not apply to code, commit
162
- messages, or MR/issue descriptions unless asked.
163
-
164
- <!--
165
- ####################################################################
166
- # 🌊✨ PLEASE ADD PROJECT SPECIFIC RULES BELOW THIS LINE ✨🌊 #
167
- # The section above is managed by the common-template-copier #
168
- # template and is updated automatically on `copier update`. #
169
- # Add your project-specific instructions below this line so #
170
- # they are preserved when the template is updated. #
171
- ####################################################################
172
- -->
173
- >>>>>>> after updating
@@ -601,6 +601,56 @@ the request rate through a limiter — no exclusions or dedup needed. A single
601
601
  `check` call emits **one** `checks_total` increment and as many
602
602
  `rule_evaluations_total` increments as rules it evaluated (possibly zero).
603
603
 
604
+ ## Block summary
605
+
606
+ `Labkit::RateLimit::BlockSummary` counts blocks per `(endpoint_id, rule)` in
607
+ one process and logs the counts every interval (300 seconds by default). The
608
+ number of lines grows with the number of distinct pairs the process blocked,
609
+ not with request rate, so an attack does not multiply log volume.
610
+
611
+ Each process tracks at most `max_pairs` pairs (10 by default) per interval.
612
+ Blocks for any further pair are counted on one `endpoint_id: "other"`,
613
+ `rate_limit_rule: "other"` line, with no `feature_category`, so a process
614
+ writes at most `max_pairs + 1` lines per flush and the tally's memory stays
615
+ bounded.
616
+
617
+ ```ruby
618
+ BLOCK_SUMMARY = Labkit::RateLimit::BlockSummary.new(logger: logger)
619
+
620
+ # On every blocked request. feature_category is optional:
621
+ BLOCK_SUMMARY.add(endpoint_id: "GET /api/:version/projects", rule: "unauthenticated_api", feature_category: "source_code_management")
622
+
623
+ # The caller owns the thread. Start it in each worker process, after any fork:
624
+ thread = Thread.new { BLOCK_SUMMARY.call }
625
+
626
+ # On shutdown, flush what is left and let the thread return:
627
+ BLOCK_SUMMARY.stop
628
+ thread.join
629
+ ```
630
+
631
+ Each flush writes one INFO line per pair and then empties the counts:
632
+
633
+ ```json
634
+ {"message": "rate_limit_block_summary", "endpoint_id": "GET /api/:version/projects", "rate_limit_rule": "unauthenticated_api", "feature_category": "source_code_management", "block_count": 412}
635
+ ```
636
+
637
+ `feature_category` is left out when the caller passed none. It does not split
638
+ a pair: the first non-nil one passed for a pair is the one logged. The first
639
+ flush waits a random part of the interval, so processes started together do
640
+ not all flush at once. A flush with nothing counted writes nothing. The caller
641
+ decides what `endpoint_id` means. LabKit does not resolve it. Counts in a
642
+ process that exits without `stop` are lost, up to one interval's worth. `stop`
643
+ only signals the thread in `call`, which runs the final flush, so it flushes
644
+ nothing if no thread is running `call`.
645
+
646
+ `interval` must be a positive number and `max_pairs` a positive Integer, or
647
+ `new` raises `ArgumentError`.
648
+
649
+ A logging failure during a flush is logged at WARN with
650
+ `message: "rate_limit_error"`, `error_type: "rate_limit_error"` and
651
+ `dropped_pairs`, the number of lines that flush lost. It never raises, so the
652
+ flush thread keeps running.
653
+
604
654
  ## Dev/test vs production guards
605
655
 
606
656
  `Limiter.new` and `Rule.new` validate names and configuration. In
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ # Counts blocks per (endpoint_id, rule) in this process and logs the counts
6
+ # every interval. A line per block grows with an attack, so lines here grow
7
+ # only with the number of distinct pairs this process blocked, and never past
8
+ # max_pairs + 1.
9
+ #
10
+ # One line per pair rather than one line holding a map: map keys become
11
+ # index fields, one per endpoint name. The fields are rate_limit_rule and
12
+ # block_count, not rule and count, so they cannot clash with another line's
13
+ # type for a common name in a shared index.
14
+ #
15
+ # The caller owns the thread: run #call on one, and #stop it on shutdown so
16
+ # the last counts are flushed.
17
+ class BlockSummary
18
+ MESSAGE = "rate_limit_block_summary"
19
+ OTHER = "other"
20
+ OTHER_KEY = [OTHER, OTHER].freeze
21
+ DEFAULT_INTERVAL_SECONDS = 300
22
+ DEFAULT_MAX_PAIRS = 10
23
+
24
+ def initialize(logger: nil, interval: DEFAULT_INTERVAL_SECONDS, max_pairs: DEFAULT_MAX_PAIRS)
25
+ # A zero interval would make #call spin without waiting.
26
+ raise ArgumentError, "interval must be a positive Numeric, got #{interval.inspect}" unless interval.is_a?(Numeric) && interval.positive?
27
+
28
+ raise ArgumentError, "max_pairs must be a positive Integer, got #{max_pairs.inspect}" unless max_pairs.is_a?(Integer) && max_pairs.positive?
29
+
30
+ @logger = logger || RateLimit.config.logger || Labkit::Logging::JsonLogger.new($stdout)
31
+ @interval = interval
32
+ @max_pairs = max_pairs
33
+ @counts = {}
34
+ @mutex = Mutex.new
35
+ @stop_signal = Thread::Queue.new
36
+ end
37
+
38
+ # Keyed on the pair alone, so feature_category never adds a line. The first
39
+ # non-nil one passed for a pair is kept. Capped here rather than at flush,
40
+ # so the hash cannot grow during an attack.
41
+ def add(endpoint_id:, rule:, feature_category: nil)
42
+ key = [endpoint_id, rule]
43
+
44
+ @mutex.synchronize do
45
+ key = OTHER_KEY if !@counts.key?(key) && pairs_full?
46
+ entry = (@counts[key] ||= [0, nil])
47
+ entry[0] += 1
48
+ entry[1] ||= feature_category unless key == OTHER_KEY
49
+ end
50
+ end
51
+
52
+ # Waits on a queue rather than sleeping, so a stop that lands mid-flush is
53
+ # still seen and the final flush is never skipped. The first wait is
54
+ # random, so workers booted together do not all flush at once.
55
+ def call
56
+ timeout = rand * @interval
57
+
58
+ loop do
59
+ stopping = @stop_signal.pop(timeout: timeout)
60
+ flush
61
+ break if stopping
62
+
63
+ timeout = @interval
64
+ end
65
+ end
66
+
67
+ # Only signals #call, which runs the final flush. Without a thread in #call,
68
+ # nothing is flushed.
69
+ def stop
70
+ @stop_signal << true
71
+ end
72
+
73
+ def flush
74
+ drained = {}
75
+ logged = 0
76
+
77
+ drained = @mutex.synchronize do
78
+ counts = @counts
79
+ @counts = {}
80
+ counts
81
+ end
82
+
83
+ drained.each do |(endpoint_id, rule), (count, feature_category)|
84
+ @logger.info(line(endpoint_id, rule, feature_category, count))
85
+ logged += 1
86
+ end
87
+ rescue StandardError => e
88
+ log_failure(e, drained.size - logged)
89
+ end
90
+
91
+ private
92
+
93
+ def pairs_full?
94
+ @counts.size - (@counts.key?(OTHER_KEY) ? 1 : 0) >= @max_pairs
95
+ end
96
+
97
+ def line(endpoint_id, rule, feature_category, count)
98
+ payload = { message: MESSAGE, Labkit::Fields::ENDPOINT_ID => endpoint_id, rate_limit_rule: rule, block_count: count }
99
+ payload[:feature_category] = feature_category if feature_category
100
+ payload
101
+ end
102
+
103
+ # Never raises: a raise here would kill the caller's flush thread.
104
+ # The counts are already drained, so dropped_pairs says how many lines
105
+ # this flush lost.
106
+ def log_failure(error, dropped_pairs)
107
+ @logger.warn(
108
+ message: "rate_limit_error",
109
+ Labkit::Fields::ERROR_TYPE => "rate_limit_error",
110
+ Labkit::Fields::CLASS_NAME => error.class.to_s,
111
+ Labkit::Fields::ERROR_MESSAGE => error.message,
112
+ dropped_pairs: dropped_pairs
113
+ )
114
+ rescue StandardError
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
@@ -25,6 +25,7 @@ module Labkit
25
25
  autoload :Evaluator, "labkit/rate_limit/evaluator"
26
26
  autoload :Limiter, "labkit/rate_limit/limiter"
27
27
  autoload :Metrics, "labkit/rate_limit/metrics"
28
+ autoload :BlockSummary, "labkit/rate_limit/block_summary"
28
29
 
29
30
  class << self
30
31
  def configure
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.0
4
+ version: 5.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -651,6 +651,7 @@ files:
651
651
  - lib/labkit/net_http_publisher.rb
652
652
  - lib/labkit/rate_limit.rb
653
653
  - lib/labkit/rate_limit/README.md
654
+ - lib/labkit/rate_limit/block_summary.rb
654
655
  - lib/labkit/rate_limit/configuration.rb
655
656
  - lib/labkit/rate_limit/evaluator.rb
656
657
  - lib/labkit/rate_limit/identifier.rb