gitlab-labkit 1.20.1 → 1.22.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: 3b916b3579ca06fa9a40b34d2bac1d9ffb5bcb8a51415ad9438956c546400164
4
- data.tar.gz: a680e485958792ac93e5be3fca0c7598db2c029349d6c7aa3c34a039daf2e350
3
+ metadata.gz: 88d5f5a7cd756153b8c69edbf0a887b0d7fbd98c0eab2828c3150cc2fafa1fac
4
+ data.tar.gz: 0bf07e6f00b14c3a31ba3dab3b3bbd7cd72af81b61dd66992e9d9ef6bd73d6da
5
5
  SHA512:
6
- metadata.gz: 53acf37dbe0f4416c5952dfba12e3df3156f233243c69580bb3dfdef6b9727f73d586151a9809225e9a99d537e2499da4f66061c197462debc76d3b3c633160a
7
- data.tar.gz: bd46d460f292290670b62801a5cb84958b187e086b662d9537df95d0ddc9af6bc1c956050d051a4c14383209b07923fbcfab0992d59c689a5c794811de0a43a2
6
+ metadata.gz: 737e6ee6b0fc00f716cf3aba4f94bd02c65c01dc5436ca8a4b99ce64ce092efe8bd39af8fe7d6d68ce5e8c2dcd1ba7c991d49b4c0c742c17a4e4ea1d48e50f1b
7
+ data.tar.gz: 17db3fe540d65f81193adfc8f86d9e984761b76b17f639bad7cd73df46206035690b1c3466bc8d54c26b7c7a3f043c6158edb657137c67b9318098b0eae61f48
data/.copier-answers.yml CHANGED
@@ -3,9 +3,10 @@
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.46.0
6
+ _commit: v1.48.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
+ gitlab_namespace: gitlab-org/ruby/gems
9
10
  golang: false
10
11
  helm: false
11
12
  initial_codeowners: '@reprazent @andrewn @mkaeppler @ayufan'
data/.gitignore CHANGED
@@ -3,3 +3,4 @@ Gemfile.lock
3
3
  node_modules
4
4
  .bundle
5
5
  /.env.sh
6
+ .idea/
data/.gitlab-ci.yml CHANGED
@@ -27,6 +27,16 @@ include:
27
27
 
28
28
  - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v3.24
29
29
 
30
+ # Attach a redis service to the rspec job from common-ci-tasks/ruby-build.
31
+ # GitLab merges keys when a local job has the same name as an included one,
32
+ # so this augments the base job (and is inherited by anything that extends it).
33
+ rspec:
34
+ services:
35
+ - name: redis:7-alpine
36
+ alias: redis
37
+ variables:
38
+ LABKIT_TEST_REDIS_URL: redis://redis
39
+
30
40
  ruby-versions:
31
41
  extends: rspec
32
42
  image: ${CI_REGISTRY}/gitlab-com/gl-infra/common-ci-tasks-images/ruby:${RUBY_VERSION}
data/README.md CHANGED
@@ -24,6 +24,7 @@ LabKit-Ruby provides functionality in a number of areas:
24
24
  1. `Labkit::FIPS` for checking for FIPS mode and using FIPS-compliant algorithms.
25
25
  1. `Labkit::Logging` for sanitizing log messages.
26
26
  1. `Labkit::Metrics` for metrics. More on the [README](./lib/labkit/metrics/README.md).
27
+ 1. `Labkit::RateLimit` for rules-based, Redis-backed rate limiting. More on the [README](./lib/labkit/rate_limit/README.md).
27
28
  1. `Labkit::RSpec` for RSpec matchers to test Labkit functionality (requires selective loading). More on the [README](./lib/labkit/rspec/README.md).
28
29
  1. `Labkit::Tracing` for handling and propagating distributed traces.
29
30
 
@@ -43,6 +44,16 @@ $ # Run tests, linters
43
44
  $ bundle exec rake verify
44
45
  ```
45
46
 
47
+ Some specs require a real Redis instance. When you run the suite locally,
48
+ it will automatically start one via `docker compose up -d redis` (see
49
+ `docker-compose.yml`) and tear it down again when the test process exits.
50
+ Redis is exposed on `localhost:6390` so it does not collide with a local
51
+ GDK/Caproni Redis on the default port.
52
+
53
+ To opt out of autostart (e.g. you've started Redis some other way), set
54
+ `LABKIT_TEST_REDIS_URL` to a reachable instance, or
55
+ `LABKIT_TEST_REDIS_NO_AUTOSTART=1` to fail loudly instead of spawning.
56
+
46
57
  Please also review the [development section of the LabKit (go) README](https://gitlab.com/gitlab-org/labkit#developing-labkit) for details of the LabKit architectural philosophy.
47
58
 
48
59
  To work on some of the scripts we use for releasing a new version,
@@ -18,7 +18,8 @@ For the architectural decision and rationale, see
18
18
 
19
19
  Offense
20
20
  : A unique combination of file path, deprecated field, and logger class.
21
- Multiple log calls in the same file using the same deprecated field count as
21
+ Multiple log calls in the same file using the same deprecated field or multiple
22
+ log calls originating from the application context count as
22
23
  one offense.
23
24
  An offense exists until the deprecated field is entirely removed from the
24
25
  file.
@@ -0,0 +1,10 @@
1
+ services:
2
+ redis:
3
+ image: redis:7-alpine
4
+ ports:
5
+ - "6390:6379"
6
+ healthcheck:
7
+ test: ["CMD", "redis-cli", "ping"]
8
+ interval: 5s
9
+ timeout: 2s
10
+ retries: 5
data/exe/labkit-logging CHANGED
@@ -176,7 +176,9 @@ module Labkit
176
176
  http.read_timeout = 30
177
177
  req = Net::HTTP::Get.new(uri.request_uri)
178
178
  req['PRIVATE-TOKEN'] = token
179
- http.request(req)
179
+ resp = http.request(req)
180
+ abort "Unauthorized: check that your GitLab token is valid and has access to this project" if resp.is_a?(Net::HTTPUnauthorized) || resp.is_a?(Net::HTTPForbidden)
181
+ resp
180
182
  end
181
183
 
182
184
  def token
@@ -15,6 +15,15 @@ module Labkit
15
15
  %r{/.*logger\.rb$}
16
16
  ].freeze
17
17
 
18
+ DEFAULT_CONTEXT_CALLSITE = "Labkit::Context"
19
+
20
+ # Placeholder logger_class used when an offense originates from the Labkit
21
+ # context. The same context fields appear in every logger that runs within
22
+ # the context, so collapsing across loggers prevents one offense per
23
+ # logger x deprecated_field combination, and prevents new offenses being
24
+ # raised whenever a developer adds a new logger class.
25
+ ANY_LOGGER = "*"
26
+
18
27
  class << self
19
28
  def register_wrapper_pattern(pattern)
20
29
  wrapper_patterns << pattern
@@ -33,6 +42,31 @@ module Labkit
33
42
  def combined_ignore_pattern
34
43
  @combined_ignore_pattern ||= Regexp.union(IGNORE_PATHS + wrapper_patterns)
35
44
  end
45
+
46
+ # The callsite name used for offenses originating from the Labkit context
47
+ # (e.g. ApplicationContext) rather than from the log caller directly.
48
+ # Override this in your application to point to the actual context provider file:
49
+ # Labkit::Logging::FieldValidator::LogInterceptor.context_callsite =
50
+ # "lib/gitlab/application_context.rb"
51
+ def context_callsite
52
+ @context_callsite || DEFAULT_CONTEXT_CALLSITE
53
+ end
54
+
55
+ attr_writer :context_callsite
56
+
57
+ def reset_context_callsite!
58
+ @context_callsite = nil
59
+ end
60
+
61
+ # The context prefix that Labkit::Context applies to every field it
62
+ # stores (see Labkit::Context::LOG_KEY). Any deprecated field with
63
+ # this prefix is by convention a context field, owned by whichever
64
+ # provider populates the context (e.g. ApplicationContext), even
65
+ # when a particular log call happens to pass it directly. Lazily
66
+ # built so the constant is not referenced at load time.
67
+ def context_field_prefix
68
+ @context_field_prefix ||= "#{::Labkit::Context::LOG_KEY}."
69
+ end
36
70
  end
37
71
 
38
72
  def format_data(severity, timestamp, progname, message)
@@ -46,6 +80,13 @@ module Labkit
46
80
 
47
81
  logger_class = self.class.name || 'AnonymousLogger'
48
82
 
83
+ # Keys the caller explicitly passed in the log message. Any deprecated field
84
+ # present in `data` but absent here arrived via Labkit::Context (e.g.
85
+ # ApplicationContext) and should be attributed to the context callsite rather
86
+ # than the individual log call site.
87
+ direct_keys = message.is_a?(Hash) ? message.transform_keys(&:to_s).keys.to_set : Set.new
88
+ context_prefix = LogInterceptor.context_field_prefix
89
+
49
90
  deprecated_lookup = Labkit::Fields::Deprecated.all
50
91
  if data.is_a?(Hash)
51
92
  data.each_key do |key|
@@ -53,11 +94,19 @@ module Labkit
53
94
  standard_field = deprecated_lookup[key_str]
54
95
  next unless standard_field
55
96
 
56
- Registry.instance.record_offense(callsite_path, location.lineno, key_str, standard_field, logger_class)
97
+ # context_prefix is the Labkit::Context namespace. The field is conceptually
98
+ # context-owned even if the caller happens to pass it directly. Otherwise
99
+ # use direct-vs-context based on whether the caller actually included it.
100
+ if direct_keys.include?(key_str) && !key_str.start_with?(context_prefix)
101
+ Registry.instance.record_offense(callsite_path, location.lineno, key_str, standard_field, logger_class)
102
+ else
103
+ Registry.instance.record_offense(LogInterceptor.context_callsite, 0, key_str, standard_field, ANY_LOGGER)
104
+ end
57
105
  end
58
106
  end
59
107
 
60
108
  Registry.instance.check_for_removed_offenses(callsite_path, data, logger_class)
109
+ Registry.instance.check_for_removed_offenses(LogInterceptor.context_callsite, data, ANY_LOGGER)
61
110
 
62
111
  data
63
112
  end
@@ -0,0 +1,329 @@
1
+ # Labkit::RateLimit
2
+
3
+ `Labkit::RateLimit` is a rules-based rate limiter backed by Redis counters. It
4
+ maintains a fixed-window counter per `(call-site, rule, characteristics)` tuple
5
+ and decides whether each request is within the configured limit.
6
+
7
+ The module is intentionally small: a `Limiter` is configured at boot with an
8
+ ordered list of `Rule`s, and every request calls `Limiter#check(identifier)` to
9
+ get back a `Result` describing what the caller should do.
10
+
11
+ ## Architecture
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ App[Application code] -->|"check(identifier)"| Limiter
16
+ Limiter -->|delegates| Evaluator
17
+ Evaluator -->|iterates ordered| Rules[Rule list]
18
+ Evaluator <-->|INCR / TTL / EXPIRE| Redis[(Redis)]
19
+ Evaluator -->|emits| Metrics[Prometheus metrics]
20
+ Evaluator -->|returns| Result
21
+ Result --> App
22
+ ```
23
+
24
+ A `Limiter` is configured once per call site and holds an `Evaluator` plus the
25
+ compiled `Rule` list. Every `check` call delegates to the same `Evaluator`,
26
+ which iterates the rules in declaration order, talks to Redis, emits metrics,
27
+ and builds a `Result`.
28
+
29
+ ## Configuration
30
+
31
+ `Labkit::RateLimit.configure` sets a global Redis connection pool and logger
32
+ that are reused across all `Limiter` instances unless a per-Limiter override is
33
+ supplied:
34
+
35
+ ```ruby
36
+ Labkit::RateLimit.configure do |c|
37
+ c.redis = ConnectionPool.new(size: 5) { Redis.new(url: ENV["REDIS_URL"]) }
38
+ c.logger = Labkit::Logging::JsonLogger.new($stdout)
39
+ end
40
+ ```
41
+
42
+ The `redis` value must respond to `.with { |conn| ... }` and yield a connection
43
+ that supports `incr`, `ttl`, `get`, `expire`, and `pipelined`. A
44
+ `ConnectionPool` of `Redis` clients is the typical choice.
45
+
46
+ The logger is used only for warnings (invalid rule names, fail-open errors,
47
+ duplicate rule names in production). It defaults to a JSON logger writing to
48
+ `$stdout`.
49
+
50
+ ## Defining a Limiter
51
+
52
+ A `Limiter` is the unit of configuration for one call site (e.g. "rack
53
+ requests", "graphql mutations", "ai actions"). Construct it once and reuse it:
54
+
55
+ ```ruby
56
+ RACK_LIMITER = Labkit::RateLimit::Limiter.new(
57
+ name: "rack_request",
58
+ rules: [
59
+ Labkit::RateLimit::Rule.new(
60
+ name: "api_user",
61
+ limit: 600,
62
+ period: 60,
63
+ characteristics: [:user],
64
+ match: { endpoint: { re: '\A/api/' } }
65
+ ),
66
+ Labkit::RateLimit::Rule.new(
67
+ name: "unauthenticated",
68
+ limit: 60,
69
+ period: 60,
70
+ characteristics: [:ip],
71
+ match: { user: nil }
72
+ )
73
+ ]
74
+ )
75
+ ```
76
+
77
+ - `name` must match `/\A[a-z0-9_]+\z/`. It is used as the first segment of
78
+ every Redis counter key for this limiter, so renaming a `Limiter` abandons
79
+ any in-flight counters.
80
+ - `rules` is an ordered array of `Rule` objects. The first rule whose `match`
81
+ hash is satisfied wins (with the exception of `:log` rules — see [Actions](#actions)).
82
+ - `redis` and `logger` are optional; they fall back to the global
83
+ `Labkit::RateLimit.config` values.
84
+
85
+ A `Labkit::RateLimit.check(name:, identifier:, rules:, ...)` convenience method
86
+ exists for one-off cases that cannot cache a `Limiter` instance, but it
87
+ allocates a fresh `Limiter` on every call and is not the recommended path.
88
+
89
+ ## Checking a request
90
+
91
+ `Limiter#check(identifier)` increments the counter for the matched rule and
92
+ returns a `Result`. `identifier` is either an `Identifier` or a plain `Hash`
93
+ of caller attributes:
94
+
95
+ ```ruby
96
+ result = RACK_LIMITER.check(
97
+ user: current_user&.id,
98
+ ip: request.ip,
99
+ endpoint: request.path
100
+ )
101
+
102
+ if result.exceeded? && result.action == :block
103
+ response.headers.merge!(result.to_response_headers)
104
+ render plain: "Too Many Requests", status: 429
105
+ return
106
+ end
107
+ ```
108
+
109
+ The `endpoint` key is treated specially: the query string is stripped at
110
+ `Identifier` construction time so URLs that vary only by query parameter share
111
+ the same counter.
112
+
113
+ ### Peeking without incrementing
114
+
115
+ `Limiter#peek(identifier)` returns the same `Result` shape but does not
116
+ mutate Redis. It is useful when one code path should account for the request
117
+ (`check`) and another should gate a side-effect on whether the caller is
118
+ already over-limit. `peek` skips `:log` rules — their state is unobservable
119
+ without incrementing.
120
+
121
+ ## Identifier
122
+
123
+ `Identifier` is a small value object wrapping a hash of caller attributes.
124
+ You can pass a `Hash` to `check`/`peek` and `Limiter` will wrap it for you, or
125
+ construct one explicitly:
126
+
127
+ ```ruby
128
+ id = Labkit::RateLimit::Identifier.new(
129
+ user: 42,
130
+ ip: "1.2.3.4",
131
+ endpoint: "/api/v4/projects/1?per_page=20" # becomes "/api/v4/projects/1"
132
+ )
133
+ ```
134
+
135
+ Keys can be symbols or strings — they are normalised to symbols on the way
136
+ in.
137
+
138
+ ## Rule
139
+
140
+ A `Rule` is a `Data.define` value object with the following fields:
141
+
142
+ | field | meaning |
143
+ |-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
144
+ | `name` | Stable identifier used in Redis keys and metric labels. Must match `/\A[a-z0-9_]+\z/`, max 64 chars. Renaming a rule abandons its in-flight counters. |
145
+ | `match` | Hash of identifier key/value predicates that must **all** be satisfied for the rule to apply. Empty hash matches anything. See [Matchers](#matchers). |
146
+ | `limit` | Integer request threshold per `period`. May be a callable resolved on every check. |
147
+ | `period` | Window length in seconds. May be a callable resolved on every check. |
148
+ | `action` | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:allow`. Default `:block`. See [Actions](#actions). |
149
+ | `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
150
+
151
+ Making `limit` or `period` callable is the supported pattern for
152
+ runtime-tunable thresholds (e.g. feature flags or database-backed settings):
153
+
154
+ ```ruby
155
+ Labkit::RateLimit::Rule.new(
156
+ name: "api_user",
157
+ limit: -> { Settings.rate_limit_api_user_per_minute },
158
+ period: 60,
159
+ characteristics: [:user]
160
+ )
161
+ ```
162
+
163
+ ### Matchers
164
+
165
+ A `match` hash gates whether a rule applies. Each value is normalised through
166
+ `Matcher.build`:
167
+
168
+ | input shape | matcher | example |
169
+ |-------------------|----------|--------------------------------------------------------|
170
+ | plain value | `eq` | `match: { user: nil }`, `match: { method: "POST" }` |
171
+ | `Regexp` | `re` | `match: { endpoint: %r{\A/api/} }` |
172
+ | `{ eq: <value> }` | `eq` | `match: { method: { eq: "POST" } }` (YAML-friendly) |
173
+ | `{ re: <source> }`| `re` | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |
174
+
175
+ `re` coerces the identifier value via `#to_s` before matching, so it can be
176
+ used against non-String values (e.g. matching a 503 status against `{ re: '^5' }`).
177
+
178
+ Glob and prefix matchers are intentionally out of scope.
179
+
180
+ ### Evaluation flow
181
+
182
+ `check` walks the rule list in order. The first **terminating** rule wins;
183
+ `:log` rules count but do not terminate, so they cannot disable a following
184
+ `:block` rule. A pure `:log`-only path still emits one `rule="unmatched"`
185
+ metric increment because no terminating rule fired.
186
+
187
+ ```mermaid
188
+ flowchart TD
189
+ Start([check identifier]) --> Iter{Next rule?}
190
+ Iter -->|yes| Match{rule.match<br/>all satisfied?}
191
+ Match -->|no| Iter
192
+ Match -->|yes| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
193
+ Eval --> Build[Build Result<br/>resolve limit/period]
194
+ Build --> Emit[Emit calls_total + limit/period gauges]
195
+ Emit --> Act{rule.action}
196
+ Act -->|":log<br/>(non-terminating)"| Iter
197
+ Act -->|:block or :allow| Return([Return Result])
198
+ Iter -->|no more rules| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
199
+ Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
200
+ Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
201
+ Error --> ReturnErr([Return error=true<br/>action=:allow])
202
+ ```
203
+
204
+ ### Actions
205
+
206
+ The rule's `action` controls how the `Result` reports an over-limit hit. The
207
+ counter is always incremented when a rule matches, regardless of `action`:
208
+
209
+ - `:block` — when exceeded, `Result#action` is `:block`. Caller should reject
210
+ the request (e.g. with HTTP 429). When under the limit, action is `:allow`.
211
+ - `:log` — **non-terminating**. The rule counts the request and records
212
+ metrics, but evaluation continues to the next rule. This is the mechanism
213
+ for shadow rules during rollout: stack a `:log` rule and a `:block` rule
214
+ together and the `:log` rule cannot disable the `:block` rule. Note that a
215
+ pure `:log`-only check still emits one `rule="unmatched"` metric entry
216
+ because no terminating rule fired.
217
+ - `:allow` — when exceeded, `Result#action` is `:allow` (rather than
218
+ `:block`). Useful for "always allow this caller even if they're over the
219
+ limit" cases while still observing them via metrics. Evaluation terminates
220
+ on the first match.
221
+
222
+ ### Redis keys
223
+
224
+ Each matched check writes a key shaped:
225
+
226
+ ```
227
+ labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
228
+ ```
229
+
230
+ Characteristic values longer than 200 bytes are replaced with a SHA-256
231
+ hexdigest to bound key length. Missing or empty characteristic values are
232
+ encoded as `_unknown_`. The TTL is set on the first write of each window
233
+ (`count == 1`) and is not extended on subsequent INCRs, so the window is a
234
+ true fixed window starting at the first request, not a sliding window.
235
+
236
+ ```mermaid
237
+ sequenceDiagram
238
+ autonumber
239
+ participant E as Evaluator
240
+ participant P as Connection pool
241
+ participant R as Redis
242
+
243
+ E->>P: pool.with { |conn| ... }
244
+ P-->>E: conn
245
+ E->>R: PIPELINE { INCR key, TTL key }
246
+ R-->>E: [count, ttl]
247
+ alt count == 1 (first write of window)
248
+ E->>R: EXPIRE key period
249
+ R-->>E: 1
250
+ Note over E: ttl returned is -1 here;<br/>build_result falls back to<br/>resolved_period for reset_at.
251
+ else count > 1
252
+ Note over E: TTL is not extended:<br/>fixed window from first write.
253
+ end
254
+ E-->>P: release conn
255
+ ```
256
+
257
+ `peek` follows the same shape but uses `GET` instead of `INCR` and never
258
+ issues `EXPIRE`. A missing key (`GET → nil`, `TTL → -2`) is reported as
259
+ `count = 0` and the window is treated as not-yet-started.
260
+
261
+ ## Result
262
+
263
+ `Result` carries the decision back to the caller:
264
+
265
+ ```ruby
266
+ result.matched? # => true if some rule matched
267
+ result.exceeded? # => true if the matched rule's counter > limit
268
+ result.action # => :block | :log | :allow
269
+ result.rule # => the matched Rule, or nil
270
+ result.error? # => true if Redis failed (see Fail-open)
271
+ result.info # => Result::Info or nil
272
+ result.to_response_headers
273
+ # => { "RateLimit-Limit" => "...", "RateLimit-Remaining" => "...", "RateLimit-Reset" => "<unix-ts>" }
274
+ ```
275
+
276
+ `Result::Info` holds the per-window counter snapshot:
277
+
278
+ | field | meaning |
279
+ |-------------------|------------------------------------------------------------------|
280
+ | `resolved_limit` | The evaluated `Integer` limit for this check. |
281
+ | `resolved_period` | The evaluated `Integer` period in seconds for this check. |
282
+ | `count` | Raw INCR value; useful for utilization-ratio metrics. |
283
+ | `remaining` | `[resolved_limit - count, 0].max`. |
284
+ | `reset_at` | Best-effort UTC `Time` when the window resets (advisory only). |
285
+
286
+ `to_response_headers` returns `{}` for an unmatched or error result, so it is
287
+ safe to merge unconditionally.
288
+
289
+ ## Fail-open
290
+
291
+ The evaluator wraps `check` and `peek` in a broad rescue. Any `StandardError`
292
+ (Redis connection failure, timeout, OOM in user-supplied callables, …) is
293
+ logged at WARN with `message: "rate_limit_error"` and returned as a
294
+ `Result(matched: false, error: true, action: :allow)`. The
295
+ `gitlab_labkit_rate_limiter_errors_total` counter is incremented. The caller
296
+ should treat the request as allowed.
297
+
298
+ ## Metrics
299
+
300
+ `Labkit::RateLimit::Metrics` emits the following Prometheus metrics through
301
+ `Labkit::Metrics::Client`:
302
+
303
+ | metric | type | labels | meaning |
304
+ |-------------------------------------------------|---------|-------------------------------------|----------------------------------------------------------------------|
305
+ | `gitlab_labkit_rate_limiter_calls_total` | counter | `rate_limiter`, `rule`, `action` | One increment per terminating decision; also incremented per matched `:log` rule. `action` is one of `"allow"`, `"block"`, `"log"`. `rule="unmatched", action="allow"` when no rule terminated. |
306
+ | `gitlab_labkit_rate_limiter_errors_total` | counter | `rate_limiter` | Fail-open events (any `StandardError` in the labkit path). |
307
+ | `gitlab_labkit_rate_limiter_limit` | gauge | `rate_limiter`, `rule` | Resolved limit at the last check (useful when `limit:` is callable). |
308
+ | `gitlab_labkit_rate_limiter_period_seconds` | gauge | `rate_limiter`, `rule` | Resolved period at the last check. |
309
+
310
+ Because `:log` rules do not terminate, a single `check` call can emit
311
+ **multiple** `calls_total` increments: one per `:log` rule that matched, plus
312
+ one for the terminating decision (or `rule="unmatched"` if no terminating
313
+ rule fired).
314
+
315
+ ## Dev/test vs production guards
316
+
317
+ `Limiter.new` and `Rule.new` validate names and configuration. In
318
+ `Labkit.dev_or_test?` mode (`RAILS_ENV` set to `development` or `test`), they
319
+ raise `ArgumentError` on:
320
+
321
+ - invalid limiter or rule names
322
+ - duplicate rule names within a single `Limiter`
323
+ - unknown `action` values
324
+ - rule names longer than 64 characters
325
+
326
+ In production, the same conditions are downgraded: invalid names are
327
+ sanitised (and the original/sanitised pair is logged), duplicate rule names
328
+ are dropped (first occurrence wins), and a warning is logged. This keeps a
329
+ misconfiguration from taking the application down at boot.
@@ -14,7 +14,8 @@ module Labkit
14
14
  # period - window in seconds; may be a callable (resolved per check)
15
15
  # action - :block (enforce), :log (count and log only, do not block,
16
16
  # evaluation continues to subsequent rules), or :allow
17
- # (bypass: short-circuit evaluation with no Redis writes)
17
+ # (count but always permit; terminates evaluation on match
18
+ # regardless of whether the limit was exceeded)
18
19
  # characteristics - identifier keys used to build the compound Redis counter key
19
20
  #
20
21
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
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: 1.20.1
4
+ version: 1.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -547,6 +547,7 @@ files:
547
547
  - config/user_experience_slis/testing_sample.yml
548
548
  - doc/FIELD_STANDARDIZATION.md
549
549
  - doc/architecture/decisions/001_field_standardization_dynamic_runtime_linting.md
550
+ - docker-compose.yml
550
551
  - exe/labkit-logging
551
552
  - gitlab-labkit.gemspec
552
553
  - lib/gitlab-labkit.rb
@@ -599,6 +600,7 @@ files:
599
600
  - lib/labkit/middleware/sidekiq/user_experience_sli/server.rb
600
601
  - lib/labkit/net_http_publisher.rb
601
602
  - lib/labkit/rate_limit.rb
603
+ - lib/labkit/rate_limit/README.md
602
604
  - lib/labkit/rate_limit/configuration.rb
603
605
  - lib/labkit/rate_limit/evaluator.rb
604
606
  - lib/labkit/rate_limit/identifier.rb