Shadow Mode
Shadow mode runs the full enforcement logic on every request but never blocks traffic. Instead it records what would have happened and emits would_reject signals that you can observe in logs and metrics.
Shadow mode is designed for:
- Canary rollout of a new policy — validate it’s correct before enforcing
- Capacity planning — measure how often a limit would fire in production
- Debugging — understand why a policy unexpectedly rejects traffic
Enabling shadow mode
Set spec.mode to "shadow" on any policy:
{
"id": "candidate-rate-limit",
"spec": {
"mode": "shadow",
"selector": { "pathPrefix": "/api/v2/" },
"rules": [{
"name": "new-per-org-limit",
"limit_keys": ["jwt:org_id"],
"algorithm": "token_bucket",
"algorithm_config": {
"tokens_per_second": 50,
"burst": 100
}
}]
}
}
The default value is "enforce".
You can also activate incident-wide shadow behavior with top-level global_shadow in the bundle (see Bundle Structure).
How it works
When a policy’s mode is shadow:
- All limiters (token bucket, cost budget, LLM limiter) are invoked normally
- Shared dict keys are namespaced with a
shadow:prefix, so shadow counters are isolated from production counters - If a limiter returns
denied, the decision is wrapped:actionis set toallow,would_reject = trueis set, and the original rejection reason is preserved asoriginal_reason - The response to the client is
200 OK— noRetry-AfterorX-Fairvisor-Reasonheaders are set - Log lines contain
"mode":"shadow"and"would_reject":true
Counter isolation means shadow mode policies accumulate spend independently and do not share state with enforce-mode policies covering the same paths.
Log output
{
"action": "allow",
"mode": "shadow",
"would_reject": true,
"original_reason": "token_bucket_exceeded",
"policy_id": "candidate-rate-limit",
"rule_name": "new-per-org-limit"
}
Filtering shadow would-rejects
docker logs fairvisor -f \
| fairvisor logs --action=allow \
| jq 'select(.would_reject == true)'
Promoting a shadow policy to enforce
- Observe the policy in shadow mode until you’re satisfied the threshold is correct
- Increment
bundle_version - Change
modefrom"shadow"to"enforce" - Deploy the updated bundle
Shadow counters are isolated, so they will not carry over. The enforce policy starts with fresh counters.
Shadow mode and loop detection
Loop detection in shadow mode uses the same shadow: key prefix for its fingerprint counters. Would-detects are logged but traffic is not throttled or rejected.
Shadow mode and circuit breaker
The circuit breaker in a shadow-mode policy checks and updates shadow-namespaced rate keys. If the breaker would have tripped, would_reject = true is set but no request is blocked.
Global shadow override (incident mode)
global_shadow is a top-level bundle override that temporarily forces all matched policies to behave like shadow mode:
{
"global_shadow": {
"enabled": true,
"reason": "incident-2026-02-20",
"expires_at": "2026-02-20T19:00:00Z"
}
}
Key properties:
- TTL-based: expires automatically at
expires_at - Runtime-only behavior: policy objects do not need to be rewritten
- Client response headers are unchanged; use logs and metrics to confirm active state