Chapter 5 of 8 · 14 min read
Segmenting the filter-rejection rate
On this page
A rejection rate is only useful if a change in it narrows down the cause. The global number changes for a dozen reasons at once — a customer's content mix shifted, a classifier was retrained, a prompt template regressed, a new endpoint launched — so it narrows nothing, and its sensitivity is set by whichever cohort supplies most of the traffic.
The arithmetic is worth doing once, because it shows that this is a structural property rather than a threshold-tuning problem. Take a service handling 200,000 model calls a day at a steady-state block rate of 0.4%, which is 800 blocks a day spread across every endpoint. A cohort of 40 users makes about 20 requests each, so 800 requests a day, of which 30% — 240 — are blocked. Total blocks go from 800 to 1,040 and the global rate goes from 0.400% to 0.520%. For an alert configured to fire when the rate doubles, the rate has to reach 0.8%, meaning 1,600 blocks; the cohort contributes at most 800, because that is every request they make. Even if every single request from all 40 users failed, all day, every day, the global rate would land exactly on the threshold. A cohort that is 0.4% of your traffic cannot move a global ratio, no matter how completely it is broken.
The general statement behind those numbers is the one to carry, because it applies to every cohort and every threshold you will ever set. A rate is blocks divided by calls. A cohort can only add to the numerator, and the most it can add is every request it makes, since a request cannot be blocked twice. So the largest possible increase in a global rate, from any one population failing completely, is that population's share of total traffic. Forty users making twenty requests each are eight hundred of two hundred thousand calls, which is the same 0.4% the block rate happens to sit at, and that coincidence is what puts the worst case exactly on a doubling threshold. Nothing about the size of the disaster inside the cohort enters the calculation. The ceiling is set by how much traffic they send.
Read that the other way round and it becomes a design rule rather than a post-mortem. Before setting any threshold on a global ratio, ask what fraction of traffic the smallest population you would want to hear about supplies. If that fraction is smaller than the movement your threshold requires, the alert cannot fire for them under any circumstances, and no amount of tuning changes it — the alert is not insensitive, it is incapable. A global rate is the right instrument only for populations large enough to move it, which in practice means the one or two cohorts that dominate your traffic.
Which segments make the number diagnostic
Each dimension you cut by removes a class of explanation.
| Segment | What a change confined to it tells you | What it rules out |
|---|---|---|
provider | The provider's policy or classifier moved | Your prompt builder, your client, anything shared across vendors |
model / deployment | A model version or that deployment's filter configuration changed | Account-wide policy, other deployments |
policy_category | One classifier's behaviour moved, or your content shifted into it | Every other category; jailbreak detectors versus harm categories |
stage (input/output) | Enforcement moved sides, or your outputs changed while inputs did not | The half of the pipeline that did not move |
endpoint | One feature's prompt template or retrieval path regressed | Every other feature sharing the same model and provider |
client_version | A release changed what you send | The provider entirely — this is yours |
cohort / tenant | One population's content differs, or one tenant's configuration does | Everyone else's traffic |
The last row is the one most teams leave out, because it feels like a business dimension rather than an operational one. It is operational: content is not uniformly distributed across customers. A mental-health clinic sends self-harm vocabulary all day, a security vendor sends exploit vocabulary, a law firm sends descriptions of violent crime. Filter exposure is a property of the workload, so a per-cohort rate is a per-workload rate, and cohort-level failure is precisely the failure mode a global ratio cannot represent.
The client_version row earns its place by ruling out the provider. When
rejection rate rises and the rise is confined to requests carrying your newest
client build, nobody needs to file an appeal; somebody needs to look at what
that build changed in the prompt. Half the value of segmentation is stopping
incidents that are yours from being escalated to a vendor.
Emitting it so those cuts exist
The segmentation has to be in the labels at emission time. A cohort dimension cannot be recovered later from a counter that never carried it.
FILTER_BLOCKS = Counter(
"llm_filter_blocks_total",
"Requests blocked by a provider content filter",
["provider", "model", "endpoint", "stage", "policy_category", "cohort", "client_version"],
)
MODEL_CALLS = Counter(
"llm_calls_total",
"Model calls attempted",
["provider", "model", "endpoint", "cohort", "client_version"], # no category: unknown on success
)
def record(result, ctx):
MODEL_CALLS.labels(result.provider, result.model, ctx.endpoint,
ctx.cohort, ctx.client_version).inc()
if isinstance(result, PolicyBlock):
for category in result.categories or ["unspecified"]:
FILTER_BLOCKS.labels(result.provider, result.model, ctx.endpoint,
result.stage, category, ctx.cohort,
ctx.client_version).inc()Two deliberate asymmetries. The denominator carries no policy_category or
stage label, because a successful call has neither, and a ratio whose numerator
and denominator have different label sets has to be joined explicitly rather
than divided blindly. And a block with multiple categories increments once per
category, which means summing across categories over-counts blocks — correct for
"how often does the self-harm classifier fire", wrong for "how many requests
failed". Keep a separate counter unlabelled by category if you need both, and
write the difference into the metric's help text, because somebody will build a
panel on the wrong one.
The denominator needs one more piece of care than it usually gets.
llm_calls_total has to be incremented for attempts, including the ones blocked
on input that never reached the model, because a denominator counting only
completed calls makes the ratio meaningless — blocks would appear in the
numerator and vanish from the denominator, and the rate would rise faster than
reality. The counter therefore belongs at the top of the adapter, before the
call, not in the success path. Where a client-side pre-filter of your own
rejects content before it is ever sent, count that on a separate metric
entirely; it is your policy, not the provider's, and folding the two together
makes a tightening of your own rules look like a vendor change.
The distortion is easiest to see by naming the two quantities. Call the refused requests the blocks and the served ones the successes. The rate you want is blocks over blocks-plus-successes, because each of those was an attempt to do a piece of work and each either happened or did not. The rate a success-only denominator computes is blocks over successes, which is larger for every value but zero and grows without limit as the block rate climbs: if half of all attempts were refused, the honest rate is a half and the mistaken one is one, and if most attempts were refused the mistaken one runs past a hundred per cent while the honest one cannot. What makes this survive code review is that the two agree to several decimal places at a steady state of a fraction of a per cent. They diverge in proportion to how bad things are, which means the number is at its most wrong on the morning somebody is reading it during an incident.
Cardinality — the number of distinct label combinations a metric can produce,
each of which your monitoring system stores and queries as a separate time
series — is the constraint that decides how far this can be taken. Multiply the
label domains: 3 providers × 6 models × 40 endpoints × 2 stages × 8 categories ×
200 cohorts × 5 client versions is 11.5 million potential series, and while only
a small fraction is ever populated, the tail is wide enough to hurt. The
practical shape is to keep high-cardinality dimensions out of the metric and in
the structured log, which already carries tenant, user, request id and reference,
and to reserve labels for cohort class — clinic-enterprise, self-serve —
rather than tenant id. When an alert fires on a cohort class, the logs give you
the specific tenants inside it. Never put user id, request id or prompt hash in
a metric label.
The reason that sum is a product rather than a total is the part people get wrong, and getting it wrong is how a metric takes down a monitoring system. A labelled counter is not one series with seven attributes. It is a separate stored series for every distinct combination of label values, because that is what makes it possible to query any slice without re-reading the raw events. So adding a label with five values does not add five series, it multiplies the existing count by five. Each new dimension multiplies again, which is why seven modest-looking domains reach eight figures. The practical form of that rule is that a label is cheap when its domain is small and fixed, and dangerous when its domain grows with your users — and the second kind is exactly the kind that looks most useful when you are writing the alert.
Rate or count, and the answer is both
A rate is the right thing to alert on when the population is large and steady, and the wrong thing when it is small, because at low volume the ratio is dominated by noise: a cohort making 12 requests an hour goes from 0% to 8.3% on a single block.
The step being skipped there is worth taking slowly, because it is the reason the problem cannot be fixed by choosing a better threshold. A rate over a segment can only take values that are whole numbers of blocks divided by that segment's call count. With twelve calls in the window, the available readings are none of twelve, one of twelve, two of twelve, and so on — and one of twelve is the 8.3%. There is no value between zero and 8.3% that the metric is capable of reporting. So a threshold set anywhere in that gap is a threshold that fires on the first block and never on a fraction of one, which makes it a count alert wearing a percentage sign. Dividing a small integer by a small integer does not produce a rate in any useful sense; it produces the same integer, rescaled.
The rule that follows is to alert on the rate, gated by a minimum sample, and separately on the count for the small populations a rate cannot serve. The first entry below is a recording rule, a query the monitoring system evaluates on a schedule and stores under a new name, so the alerts built on top stay cheap to evaluate.
groups:
- name: filter-rejections
rules:
# Baseline per segment: 6-hour trailing rate, recorded so alerts stay cheap.
- record: filter:block_rate:6h
expr: |
sum by (provider, model, endpoint, cohort, policy_category) (
rate(llm_filter_blocks_total[6h])
)
/
sum by (provider, model, endpoint, cohort) (rate(llm_calls_total[6h]))
# Rate alert: only where the segment has enough traffic to make a ratio mean anything.
- alert: FilterBlockRateShiftInSegment
expr: |
(
filter:block_rate:1h
> 3 * (filter:block_rate:6h offset 1d)
+ 0.01
)
and
sum by (provider, model, endpoint, cohort) (increase(llm_calls_total[1h])) > 200
for: 30m
labels: { severity: page }
annotations:
summary: "{{ $labels.cohort }} on {{ $labels.endpoint }}: block rate {{ $value | humanizePercentage }}"
# Count alert: catches the small cohort a ratio cannot represent.
- alert: FilterBlocksConcentratedInSmallCohort
expr: |
sum by (cohort, endpoint, policy_category) (increase(llm_filter_blocks_total[1h])) > 20
and
sum by (cohort, endpoint) (increase(llm_calls_total[1h])) < 200
for: 30m
labels: { severity: ticket }
# A category that has never fired starting to fire is a policy change until proven otherwise.
- alert: NewPolicyCategoryFiring
expr: |
sum by (provider, model, policy_category) (increase(llm_filter_blocks_total[1h])) > 5
unless
sum by (provider, model, policy_category) (increase(llm_filter_blocks_total[7d] offset 1h)) > 0
labels: { severity: page }The additive + 0.01 term in the rate alert stops a segment moving from 0.05%
to 0.2% from paging; a threefold move on a tiny base is arithmetically dramatic
and operationally nothing. The for: 30m clause absorbs the ten-minute blips a
deploy or a retrieval hiccup produces. And NewPolicyCategoryFiring catches
what rates are worst at: a brand-new category appearing is a step change from
zero, where no ratio comparison is defined, and it is the single strongest
indicator that the provider changed something rather than you did.
The controlled comparison you already have
An alert tells you a segment moved. It does not tell you whether the provider changed their policy or you changed your request-building, and those go to different teams with different urgency. When one endpoint moves overnight from 0.3% to 4% with no deploy on your side, the first check is not to read the diff.
Send the same traffic to a second provider and compare. If the rise is confined to one provider or model while identical inputs on another are unaffected, the input did not change and the classifier did. If both rise together, the input changed, and "no deploy on your side" is a weaker claim than it sounds: a retrieval corpus updated, a customer started uploading a new document type, an upstream service began appending a disclaimer to every prompt.
What makes that a controlled comparison rather than a second opinion is the thing the two paths share and the thing they do not. Both receive the same inputs, from the same users, over the same window. They differ in exactly one respect: which vendor's classifier scores the text. So the two hypotheses on the table — the content moved, or the policy moved — predict different observations. Content moving is a property of the input, and the input is common to both paths, so it must show up on both. A policy moving is a property of one vendor's software, so it can only show up on one. A single number rising tells you nothing because both hypotheses predict it; two numbers, one rising and one flat, is only consistent with one of them. This is the entire value of a control, and it is why the shadow path is worth its cost even though nobody ever reads its responses.
Most teams already have the second path — a fallback provider, a cheaper model for spillover, a staging deployment in a different region — and the cost of using it as a control is a shadow sample rather than a full mirror, a shadow sample being a copy of a slice of live traffic sent to the second target purely to observe what happens, with the response discarded rather than shown to anyone. Two hundred requests an hour, replayed to the secondary and evaluated only for their block verdict, is enough to make the comparison and cheap enough that nobody argues about it. Where traffic cannot be duplicated for data-residency reasons, the fixture suite from the previous chapter is the degraded substitute: it runs a fixed set of inputs against both providers on a schedule and tells you whether known-good and known-blocked inputs still land where they used to.
# One provider's rate rising while the other holds is the signature of a policy change.
filter:block_rate:1h{provider="azure-openai", endpoint="/summarize"}
/ ignoring(provider) group_left
filter:block_rate:1h{provider="secondary", endpoint="/summarize"}
> 4A comparison only means something if both sides are running the same inputs, which sounds obvious and is where these setups usually rot. A shadow path that quietly stops receiving traffic, or that receives a truncated prompt because the secondary adapter was never updated when the primary's template changed, produces a stable ratio of one number to a stale number and gives you confident nonsense. Alert on the shadow path's own request volume, and treat a silent secondary as an outage of your diagnostic capability rather than as a saving.
A rising rate is not evidence of a false positive
A rejection rate counts verdicts, and a rise can mean the policy tightened, your
users' content shifted, or someone is genuinely abusing the product. A spike on
jailbreak in a self-serve cohort at 02:00 is more likely to be the third than
the first, and treating every rise as a false positive will lead you to file
appeals that damage your standing with the provider.
It is also a poor detector of slow drift. A classifier retrained monthly can walk a segment from 0.3% to 1.2% over six weeks without any single hour tripping a threshold, and week-over-week comparison catches that only if somebody looks. A weekly review of the top ten segments by absolute block count is a low-tech control that works where alerting does not.
And it says nothing about impact. Four hundred blocks a day on a best-effort suggestion feature is noise; forty a day on a clinical workflow with a signed availability commitment is an incident. The metric measures the mechanism; the severity depends on what the blocked request was for, and that judgment cannot be automated from a counter.
What segmentation buys is the ability to ask a question that has an owner. A global rate moving tells you to investigate everything. A rate moving on one provider, one category and one cohort class, while every other segment holds still, tells you the provider's classifier changed for a population whose content you can name — and a rate moving on one client version tells you not to email the vendor at all. Both answers arrive in the same hour rather than in the second week.
Which raises the question this incident actually turned on for eleven days — not what the dashboard says, but what the clinician sees on screen when an assessment fails, given that the true answer is neither reassuring nor something you can put in a user interface verbatim.
Provider content filters as an operational problem — read the current version at https://daviddominguez.dev/learn/en/provider-content-filters-as-an-operational-problem/segmenting-the-filter-rejection-rate