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 +4 -4
- data/.copier-answers.yml +1 -1
- data/AGENTS.md +34 -38
- data/lib/labkit/rate_limit/README.md +50 -0
- data/lib/labkit/rate_limit/block_summary.rb +119 -0
- data/lib/labkit/rate_limit.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbffb0883d80c949bfb28ffec2943b9cc89ffb88c2c02656afe99ad777e0f152
|
|
4
|
+
data.tar.gz: 93610eb46b3dbbfebe40c1b97d31d07b69af3f5e5ee0f152be29a66eca8da03e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
2
|
-
|
|
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
|
data/lib/labkit/rate_limit.rb
CHANGED
|
@@ -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.
|
|
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
|