Commit 1de2c725 authored by Ashwin S's avatar Ashwin S
Browse files

feat(rate_limit): add per-check and per-rule-evaluation counters

calls_total conflates per-rule decisions with per-check outcomes:
summing it counts rule evaluations rather than requests, the
rule=unmatched placeholder is ambiguous, and failed-open checks are
only visible in errors_total, which shares no denominator.

Add two counters:

- checks_total{rate_limiter, action, matched, error}: exactly one
  increment per check call, including fail-open. error=true also
  covers count_distinct missing-key skips (Result#degraded?), so
  checks_total{error=true} / checks_total is the error fraction.
- rule_evaluations_total{rate_limiter, rule, action, result}: one
  increment per evaluated rule; action is configured, result decided
  (an exceeded :log rule reports result=log).

The per-check counter is a new name rather than a reshaped calls_total:
prometheus-client-mmap allows one label signature per metric name, so
the shapes cannot coexist. calls_total and errors_total keep emitting
unchanged (deprecated) until consumers migrate; removal is a follow-up
major release. Check-path metric emission is now best-effort so a
metrics failure cannot alter a verdict or break fail-open.

Part of
gitlab-com/gl-infra/production-engineering#29519
parent c9bc814f
Loading
Loading
Loading
Loading
Loading
+71 −31
Original line number Diff line number Diff line
@@ -219,7 +219,7 @@ An exceeded rule has `remaining` 0 and therefore outranks any rule still under
its limit. For a `:log` rule that means shadow traffic surfaces to the caller
as `exceeded? == true` (with `action` still `:allow`) — visible, but unable to
block. A `:log`-only path that matches therefore returns `matched? == true`
and emits no `rule="unmatched"` metric.
and its check is counted as `checks_total{matched="true"}`.

```mermaid
flowchart TD
@@ -227,20 +227,20 @@ flowchart TD
    Iter -->|yes| Match{rule.match<br/>all satisfied?}
    Match -->|no| Iter
    Match -->|yes| Skip{rule.action<br/>== :skip?}
    Skip -->|"yes (no Redis op)"| SkipEmit[Emit calls_total<br/>action=skip]
    Skip -->|"yes (no Redis op)"| SkipEmit[Emit rule_evaluations_total<br/>action=skip, result=skip]
    SkipEmit --> SkipReturn([Return matched=true<br/>action=:allow])
    Skip -->|no| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
    Eval --> Build[Build Evaluation<br/>resolve limit/period]
    Build --> Add[Add evaluation to Result]
    Add --> Emit[Emit calls_total + limit/period gauges]
    Add --> Emit[Emit rule_evaluations_total<br/>+ limit/period gauges]
    Emit --> Act{"result.block?<br/>(:limit rule over limit)"}
    Act -->|yes| Return([Return Result<br/>action=:block])
    Act -->|"no (:log, or :limit under limit)"| Iter
    Iter -->|no more rules| Any{any rule<br/>evaluated?}
    Any -->|yes| ReturnFold([Return Result reporting<br/>most-constraining evaluation])
    Any -->|no| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
    Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
    Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
    Iter -->|no more rules| Return2([Return Result reporting<br/>most-constraining evaluation,<br/>or matched=false if none])
    Return --> Check[Emit checks_total<br/>action, matched, error]
    SkipReturn --> Check
    Return2 --> Check
    Eval -. StandardError .-> Error[Emit errors_total +<br/>checks_total error=true<br/>log warn]
    Error --> ReturnErr([Return error=true<br/>action=:allow])
```

@@ -249,23 +249,41 @@ discards the verdicts of the rules already evaluated, even though their counters
were incremented. Those requests are counted but produce no verdict — the
fail-open trade-off is that a request is never blocked on a partial evaluation.

Note that `calls_total` is emitted **per matched rule**, so summing it by
`rate_limiter` counts rule evaluations, not requests.
`rule_evaluations_total` is emitted **per evaluated rule** (including matched
`:skip` rules); `checks_total` is emitted **exactly once per check**, whatever
path the evaluation takes — so summing `checks_total` by `rate_limiter` counts
requests through the limiter, and "no rule matched" is `matched="false"` on the
check rather than a placeholder rule.

Only matched rules are evaluated: a rule whose `match:` conditions are not
satisfied emits nothing to `rule_evaluations_total` (match-testing is not an
evaluation), so the counter has no "didn't match" population and needs no
label for it. A matched `count_distinct` rule skipped by the missing-key
fail-open also emits nothing here — it was never evaluated; that check is
visible via `checks_total{error="true"}` and the
`rate_limit_missing_count_distinct` log, which carries the rule name.

During the transition the deprecated `calls_total` counter is additionally
emitted at every point the diagram emits `rule_evaluations_total` (with its
historical per-rule semantics, including the `rule="unmatched"` placeholder
after the loop). It is not shown above to keep the diagram legible; see the
Metrics table below.

### Actions

The rule's `action` describes what the rule does; the result's `action`
describes the outcome — what the caller should do — and is only ever `:allow`
or `:block`. The counter is always incremented when a rule matches, except for
`:skip` rules, which never touch Redis:

| rule action | what it does                                  | exceeded? | result action | terminating?  |
|-------------|-----------------------------------------------|-----------|---------------|---------------|
| `:limit`    | count against the limit                       | no        | `:allow`      | no — continue |
| `:limit`    | count against the limit                       | yes       | `:block`      | yes — stop    |
| `:log`      | count against the limit (observability only)  | no        | `:allow`      | no — continue |
| `:log`      | count against the limit (observability only)  | yes       | `:allow`      | no — continue |
| `:skip`     | don't count (bypass)                          | n/a       | `:allow`      | yes — stop    |
or `:block`. The Redis counter is always incremented when a rule matches,
except for `:skip` rules, which never touch Redis. The `result` label on
`rule_evaluations_total` records what each evaluation decided:

| rule action | what it does                                  | exceeded? | result action | `result` label | terminating?  |
|-------------|-----------------------------------------------|-----------|---------------|----------------|---------------|
| `:limit`    | count against the limit                       | no        | `:allow`      | `allow`        | no — continue |
| `:limit`    | count against the limit                       | yes       | `:block`      | `block`        | yes — stop    |
| `:log`      | count against the limit (observability only)  | no        | `:allow`      | `allow`        | no — continue |
| `:log`      | count against the limit (observability only)  | yes       | `:allow`      | `log`          | no — continue |
| `:skip`     | don't count (bypass)                          | n/a       | `:allow`      | `skip`         | yes — stop    |

- `:limit` — when exceeded, `Result#action` is `:block` and evaluation
  terminates. Caller should reject the request (e.g. with HTTP 429). When under
@@ -281,8 +299,8 @@ or `:block`. The counter is always incremented when a rule matches, except for
  `Result#action` `:allow` **without any Redis operation**: nothing is
  counted, so `limit`, `period`, `characteristics`, and `count_distinct` are
  inert and the result carries no `info` (`to_response_headers` is `{}`).
  The match is still observable via `calls_total{action="skip"}`. Use this
  for bypasses.
  The match is still observable via `rule_evaluations_total{action="skip"}`.
  Use this for bypasses.

### Redis keys

@@ -375,26 +393,48 @@ safe to merge unconditionally.

The evaluator wraps `check` and `peek` in a broad rescue. Any `StandardError`
(Redis connection failure, timeout, OOM in user-supplied callables, …) is
logged at WARN with `message: "rate_limit_error"` and returned as an error
`Result` (`matched?` false, `error?` true, `action` `:allow`). The
`gitlab_labkit_rate_limiter_errors_total` counter is incremented. The caller
logged at WARN with `error_type: "rate_limit_error"` and returned as an error
`Result` (`matched?` false, `error?` true, `action` `:allow`). The caller
should treat the request as allowed.

A failed-open `check` is still counted: it emits
`checks_total{action="allow", matched="false", error="true"}`, so the fraction
of checks that encountered an error is `checks_total{error="true"}` over
`checks_total`. `error="true"` also covers a check where a matched
`count_distinct` rule was skipped because the identifier was missing its
`count_distinct` key — that check completes (`action` and `matched` describe
its outcome as usual), so `error="true"` is not exclusively fail-open traffic.

The deprecated `gitlab_labkit_rate_limiter_errors_total` counter is still
incremented on every fail-open (whole-check and per-rule `count_distinct`),
and remains the only error metric for `peek`, which emits no `checks_total`
(peek must not inflate the per-check counter). The follow-up MR that removes
`errors_total` must first decide where `peek` errors go — a dedicated peek
metric, or logs only.

## Metrics

`Labkit::RateLimit::Metrics` emits the following Prometheus metrics through
`Labkit::Metrics::Client`:

| metric                                              | type    | labels                                       | meaning                                                              |
|-------------------------------------------------|---------|-------------------------------------|----------------------------------------------------------------------|
| `gitlab_labkit_rate_limiter_calls_total`        | counter | `rate_limiter`, `rule`, `action`    | One increment per counted rule (plus one per matched `:skip` rule). `action` is the rule-level outcome: `"allow"` (under limit), `"limit"` (blocking `:limit` rule), `"log"` (exceeded `:log` rule), `"skip"`. `rule="unmatched", action="allow"` when no rule matched. |
| `gitlab_labkit_rate_limiter_errors_total`       | counter | `rate_limiter`                      | Fail-open events (any `StandardError` in the labkit path).            |
|-----------------------------------------------------|---------|----------------------------------------------|----------------------------------------------------------------------|
| `gitlab_labkit_rate_limiter_checks_total`           | counter | `rate_limiter`, `action`, `matched`, `error` | Exactly one increment per `check` call, including fail-open. `action` is what the caller should do (`"allow"` or `"block"`); `matched` and `error` are `"true"`/`"false"`. |
| `gitlab_labkit_rate_limiter_rule_evaluations_total` | counter | `rate_limiter`, `rule`, `action`, `result`   | One increment per evaluated rule (plus one per matched `:skip` rule). `action` is the configured rule action (`"limit"`, `"log"`, `"skip"`); `result` is what the evaluation decided (`"allow"`, `"block"`, `"log"`, `"skip"` — see the Actions table). |
| `gitlab_labkit_rate_limiter_calls_total`            | counter | `rate_limiter`, `rule`, `action`             | **Deprecated** — superseded by `checks_total` + `rule_evaluations_total`. Historical per-rule counter: one increment per counted rule (plus one per matched `:skip` rule), `rule="unmatched", action="allow"` when no rule matched. Emitted unchanged during the transition. |
| `gitlab_labkit_rate_limiter_errors_total`           | counter | `rate_limiter`                               | **Deprecated** — use `checks_total{error="true"}`. Fail-open events (any `StandardError` in the labkit path); still the only error metric for `peek`. |
| `gitlab_labkit_rate_limiter_limit`                  | gauge   | `rate_limiter`, `rule`                       | Resolved limit at the last check (useful when `limit:` is callable). |
| `gitlab_labkit_rate_limiter_period_seconds`         | gauge   | `rate_limiter`, `rule`                       | Resolved period at the last check.                                   |

Because every matching rule is counted, a single `check` call can emit
**multiple** `calls_total` increments: one per counted rule (or a single
`rule="unmatched"` increment if nothing matched).
`sum by (rate_limiter) (rate(gitlab_labkit_rate_limiter_checks_total[5m]))` is
the request rate through a limiter — no exclusions or dedup needed. A single
`check` call emits **one** `checks_total` increment and as many
`rule_evaluations_total` increments as rules it evaluated (possibly zero).

**Transition:** the deprecated `calls_total` and `errors_total` counters keep
emitting exactly as before this split, so existing dashboards and alerts stay
correct while consumers migrate to the new counters. Both are removed in a
follow-up major release once nothing consumes them.

## Dev/test vs production guards

+52 −12
Original line number Diff line number Diff line
@@ -75,11 +75,17 @@ module Labkit

      def check(identifier, cost: 1, rule_context: nil)
        cursor = RuleCursor.new
        check_rules(identifier, cost, rule_context, cursor)
        result = check_rules(identifier, cost, rule_context, cursor)

        # Setting to nil as a raise from the per-check emission belongs to no rule
        cursor.rule = nil
        report_check_metrics(result)
        result
      rescue StandardError => e
        # Intentionally broad: fail-open applies to any unexpected error (network,
        # timeout, OOM) not only Redis protocol errors.
        report_error_metrics
        report_check_metrics(Result.error)
        log_error(e, identifier, cursor.rule)
        Result.error
      end
@@ -104,7 +110,7 @@ module Labkit
      # - :skip terminates on match without touching Redis. No counter is
      #   incremented, so the branch sits before the count_distinct check
      #   (identifier completeness is irrelevant to a rule that builds no key).
      #   calls_total still increments so the bypass stays observable.
      #   rule_evaluations_total still increments so the bypass stays observable.
      # - a :limit rule over its limit terminates, because the request is
      #   rejected and later rules cannot change that. Rules declared after it
      #   are neither counted nor evaluated, so a blocked request debits every
@@ -115,10 +121,14 @@ module Labkit
      # most-constraining evaluation (ranking lives in Result::Evaluation#<=>).
      # cost is therefore debited from every matching rule, not just the first.
      #
      # Metrics: each evaluated rule emits rule_evaluations_total (plus the
      # deprecated per-rule calls_total); the per-check checks_total is
      # emitted once in #check. Full contract in the README's Metrics section.
      #
      # SET-mode rules (rule.count_distinct set) that match but whose identifier
      # is missing the count_distinct key fail open + log + bump errors_total, and
      # the loop continues to the next rule (the rule is treated as not applicable
      # rather than aborting the whole evaluation).
      # is missing the count_distinct key fail open + log + bump errors_total +
      # flag the Result, and the loop continues to the next rule (the rule is
      # treated as not applicable rather than aborting the whole evaluation).
      #
      # Error handling stays whole-check (see #check): a raise part-way through
      # discards the results of the rules already evaluated, even though their
@@ -143,12 +153,13 @@ module Labkit
          if rule.count_distinct && missing_count_distinct_value?(rule, identifier)
            log_missing_count_distinct(rule, identifier)
            report_error_metrics
            result.degraded!
            next
          end

          evaluation = evaluate_rule(rule, identifier, cost, rule_context)
          result.add_evaluation(evaluation)
          report_matched_metrics(evaluation)
          report_evaluation_metrics(evaluation)
          return result if result.block?
        end

@@ -362,7 +373,14 @@ module Labkit
        )
      end

      def report_matched_metrics(evaluation)
      def report_evaluation_metrics(evaluation)
        Metrics.rule_evaluations_total.increment(
          rate_limiter: @name,
          rule: evaluation.rule.name,
          action: evaluation.rule.action.to_s,
          result: evaluation_result(evaluation)
        )
        # Deprecated dual emission - remove together with Metrics.calls_total.
        Metrics.calls_total.increment(
          rate_limiter: @name,
          rule: evaluation.rule.name,
@@ -378,17 +396,26 @@ module Labkit
        )
      end

      # calls_total carries action="skip" (the rule action, not the :allow the
      # caller sees) so bypass traffic stays distinguishable from counted
      # allows. No limit/period gauges: a skip rule has no limit to report.
      # An exceeded :log rule reports "log" rather than the "allow" the caller
      # sees, so shadow rules over their limit stay visible.
      def evaluation_result(evaluation)
        return "allow" unless evaluation.exceeded?

        evaluation.rule.action == :limit ? "block" : "log"
      end

      def report_skipped_metrics(rule)
        Metrics.calls_total.increment(
        Metrics.rule_evaluations_total.increment(
          rate_limiter: @name,
          rule: rule.name,
          action: "skip"
          action: "skip",
          result: "skip"
        )
        # Deprecated dual emission - remove together with Metrics.calls_total.
        Metrics.calls_total.increment(rate_limiter: @name, rule: rule.name, action: "skip")
      end

      # Deprecated dual emission - remove together with Metrics.calls_total.
      def report_unmatched_metrics
        Metrics.calls_total.increment(
          rate_limiter: @name,
@@ -397,8 +424,21 @@ module Labkit
        )
      end

      def report_check_metrics(result)
        Metrics.checks_total.increment(
          rate_limiter: @name,
          action: result.action.to_s,
          matched: result.matched?.to_s,
          error: (result.error? || result.degraded?).to_s
        )
      rescue StandardError
        nil
      end

      def report_error_metrics
        Metrics.errors_total.increment(rate_limiter: @name)
      rescue StandardError
        nil
      end
    end
  end
+25 −4
Original line number Diff line number Diff line
@@ -5,10 +5,19 @@ module Labkit
    module Metrics
      module_function

      # Emitted once per *matched rule*, not once per check: every rule that
      # matches is evaluated, so summing this by rate_limiter counts rule
      # evaluations rather than requests. rule="unmatched", action="allow" is
      # emitted only when no rule matched at all.
      # Emitted exactly once per #check call, including calls that fail open;
      # summing by rate_limiter gives the request rate through the limiter.
      def checks_total
        Labkit::Metrics::Client.counter(
          :gitlab_labkit_rate_limiter_checks_total,
          'Total number of rate limit checks',
          { rate_limiter: nil, action: nil, matched: nil, error: nil }
        )
      end

      # Deprecated: superseded by checks_total and rule_evaluations_total.
      # Emitted unchanged (once per matched rule, rule="unmatched" when none
      # matched) until consumers migrate; will get removed in a follow-up release.
      def calls_total
        Labkit::Metrics::Client.counter(
          :gitlab_labkit_rate_limiter_calls_total,
@@ -17,6 +26,18 @@ module Labkit
        )
      end

      # Emitted once per *evaluated* rule. Only matched rules are evaluated:
      # Unmatched traffic is checks_total{matched="false"}
      def rule_evaluations_total
        Labkit::Metrics::Client.counter(
          :gitlab_labkit_rate_limiter_rule_evaluations_total,
          'Total number of rate limit rule evaluations',
          { rate_limiter: nil, rule: nil, action: nil, result: nil }
        )
      end

      # Deprecated: superseded by checks_total{error="true"}. Still emitted
      # because it remains the only error metric for #peek.
      def errors_total
        Labkit::Metrics::Client.counter(
          :gitlab_labkit_rate_limiter_errors_total,
+14 −0
Original line number Diff line number Diff line
@@ -25,6 +25,8 @@ module Labkit
    #               false). Other rules may also have matched and been counted;
    #               see #evaluations.
    # error?      - true if Redis was unavailable; result fails open (exceeded? is false)
    # degraded?   - true if a matched count_distinct rule was skipped by the
    #               missing-key fail-open path; the check still completed
    # info        - Result::Info with per-window counters for the reported rule;
    #               nil when matched? is false, error?, or the matched rule is
    #               :skip (no counter exists)
@@ -40,6 +42,7 @@ module Labkit
        @evaluations = []
        @skip_rule = nil
        @error = error
        @degraded = false
        @most_constraining = nil
      end

@@ -78,6 +81,17 @@ module Labkit
        @error
      end

      # Unlike error?, the check keeps going: the flag marks the outcome as
      # degraded without changing it.
      def degraded!
        @degraded = true
        self
      end

      def degraded?
        @degraded
      end

      def action
        block? ? :block : :allow
      end
+210 −16

File changed.

Preview size limit exceeded, changes collapsed.

Loading