# Outage: Production relay metrics-cardinality OOM
## Incident metadata
- **Date:** 2026-09-02
- **Severity:** Critical
- **Status:** Resolved operationally
- **Resolved:** 2026-09-05T19:53:19Z
- **Component:** production token.place relay and its application metrics endpoint
- **Incident ID:** `2026-09-02-production-relay-metrics-cardinality-oom`
- **Primary incident window:** approximately 2026-09-02 16:41 PDT / 23:41 UTC through
2026-09-02 18:08:18 PDT / 2026-09-03 01:08:18 UTC
## Summary
On September 2, 2026, the sole production token.place relay entered an approximately 87-minute
crash loop. Default Flask request instrumentation retained raw or effectively unbounded HTTP path
values as Prometheus labels. A rapid increase in distinct unmatched paths grew the application
metric set from approximately 29,000 Flask series to approximately 71,000. Collection and
serialization of that multiprocess metric set increased `/metrics` response size and latency while
relay memory approached its 256Mi hard limit, after which Kubernetes OOM-killed the process.
Multiprocess metric state used pod-lifetime temporary storage without startup cleanup. An ordinary
application-container restart could therefore retain the unsafe exposition state and allow the OOM
cycle to repeat. Because production had one relay replica, a crash could
temporarily remove the only backend and produce Traefik's browser-visible `no available server`
response.
The source repository's `main` branch already contained bounded-registry hardening, including a
dedicated relay registry, disabled default exports, normalization of unmatched routes to `other`,
and a bounded-scrape regression test. The deployed `e46277d` image did not descend from that
hardening merge and still initialized default Flask metrics. This confirmed release-line/source-
provenance mismatch left the runtime mechanism present in production.
At 2026-09-03 01:07:50 UTC, an operator paused Prometheus discovery of only the token.place target
and deleted only the affected pod. Kubernetes created a healthy replacement with a fresh
pod-level `emptyDir`; the image and Deployment were not rolled back or changed. Production serves
traffic again, but application scraping remained intentionally paused until the recovery rollout
described below. The bounded-cardinality backport has since been deployed and authenticated
scraping restored. The operator classified the incident as **Resolved operationally** at `2026-09-05T19:53:19Z`:
service and observability were restored on the corrected deployment. That operational boundary
does not assert that every original qualification or issue-closeout requirement passed. The
operator reported the combined recovery image's staging qualification complete before deployment;
this public summary does not reproduce the complete staging evidence, and independent evidence
review and the 24-hour stability requirements remain undocumented.
As of 2026-09-06, maintainers independently closed the completed-work trackers
[token.place #1765](https://github.com/futuroptimist/token.place/issues/1765#issuecomment-5560895881),
[token.place #1766](https://github.com/futuroptimist/token.place/issues/1766#issuecomment-5560900290),
[sugarkube #2774](https://github.com/futuroptimist/sugarkube/issues/2774#issuecomment-5560902169),
and [sugarkube #2775](https://github.com/futuroptimist/sugarkube/issues/2775#issuecomment-5560903530).
Those closures do not establish every broader acceptance criterion or long-duration observation.
## Impact
- Production was intermittently unavailable during an approximately 87-minute crash loop, from
the first Kubernetes-confirmed restart/OOM evidence at approximately 23:41 UTC until the
replacement pod became healthy at 01:08:18 UTC. This was not measured as continuous downtime.
- While the sole relay pod was unavailable, browsers could receive Traefik's
`no available server` response.
- The Deployment and Service reported an available endpoint during some healthy intervals between
crashes, consistent with intermittent rather than continuous impact.
- Repeated process termination created an unquantified risk of losing active API-v1 correctness
state because authoritative relay state remained memory-backed. No customer data loss or
in-memory work loss was proven.
- Affected-user counts, failed-request counts, revenue impact, and exact request-loss duration were
not measured and are not estimated here.
- Severity is **Critical** because a production PagerDuty alert accompanied user-visible
unavailability of the only relay replica.
## Detection
The incident was detected through the production PagerDuty alert and user-visible unavailability.
The exact PagerDuty trigger and acknowledgement timestamps are absent from the sanitized evidence,
so this record does not assign a precise page-fire time.
Kubernetes was the authoritative source for the OOM determination: the previous container state
reported reason `OOMKilled`, exit code `137`, and finish time `2026-09-03T00:50:09Z`. The historical
restart counter and `last_termination_oom` signal first appeared at 23:41 UTC, and the Prometheus
target was down by 23:47 UTC. The `container_oom_events` query remained zero and is not treated as
evidence that no OOM occurred.
## Timeline
All incident times are shown in PDT and UTC. Prometheus observations came from the
`2026-09-02T23:20:00Z` through `2026-09-03T01:08:00Z` historical window at 30-second resolution;
sampled maxima may be lower than instantaneous peaks.
| Time (PDT) | Time (UTC) | Event |
| --- | --- | --- |
| 2026-08-27 11:10:41 | 2026-08-27 18:10:41 | The ServiceMonitor was created. It remained generation 1 before mitigation and was already scraping every 30 seconds; it was not activated at incident onset. |
| 2026-09-02 16:20:00 | 2026-09-02 23:20:00 | Historical evidence window began. Working set was 197,107,712 bytes. |
| 2026-09-02 16:30:00 | 2026-09-02 23:30:00 | Target was up. Working set was 197,111,808 bytes and RSS was 187,510,784 bytes. Metrics included 1,679 distinct path labels, 1,659 unknown paths, 28,964 Flask series, and 28,996 total samples; scrape duration was 3.102 seconds. |
| 2026-09-02 16:39:30 | 2026-09-02 23:39:30 | Maximum sampled working set was 247,996,416 bytes: `247,996,416 / 268,435,456 × 100 = 92.385%`, or approximately 92.4% of the 256Mi limit. |
| 2026-09-02 16:40:00 | 2026-09-02 23:40:00 | Target remained up. Maximum sampled RSS was 242,376,704 bytes. Cardinality reached 2,741 distinct paths, 2,719 unknown paths, 47,018 Flask series, and 47,050 total samples. `scrape_series_added` was 2,261 and scrape duration was 6.150 seconds. |
| Approximately 2026-09-02 16:41 | Approximately 2026-09-02 23:41 | The first historical restart and `last_termination_oom` signal appeared. `scrape_series_added` reached its observed maximum of 2,822. This is the first Kubernetes-confirmed OOM/restart evidence. |
| 2026-09-02 16:46:30 | 2026-09-02 23:46:30 | Metrics reached 4,124 distinct paths, 4,093 unknown paths, and 70,529 Flask series. |
| 2026-09-02 16:47:00 | 2026-09-02 23:47:00 | Prometheus target was down and the restart counter was 2. |
| 2026-09-02 16:48:00 | 2026-09-02 23:48:00 | Observed maxima reached 4,155 distinct paths, 4,121 unknown paths, 71,056 Flask series, and 71,088 scraped samples. |
| 2026-09-02 16:55:00 | 2026-09-02 23:55:00 | Restart counter was 5. |
| 2026-09-02 17:05:00 | 2026-09-03 00:05:00 | Restart counter was 7. |
| 2026-09-02 17:50:09 | 2026-09-03 00:50:09 | Kubernetes recorded the prior container termination as `OOMKilled`, exit code 137. |
| During initial triage | During initial triage | The affected pod had reached 15 restarts. Kubernetes events included 251 BackOff events over approximately 73 minutes. A later historical query showed a maximum restart count of 16. |
| 2026-09-02 18:07:50 | 2026-09-03 01:07:50 | After verifying the exact production target, the operator changed only its discovery selector so it no longer matched, then deleted only the OOM-looping pod. No image rollback or workload change occurred. |
| 2026-09-02 18:08:18 | 2026-09-03 01:08:18 | Replacement pod started, became ready, and ended the confirmed impact window with a fresh pod-level `emptyDir`. |
| 2026-09-05 12:10:05 | 2026-09-05 19:10:05 | Observed Helm revision 7 upgrade deployed the identity-verified combined recovery image. |
| 2026-09-05 12:32:18 | 2026-09-05 19:32:18 | Production cutover verification began; request and compute lifecycles succeeded through a bounded two-minute soak. |
| 2026-09-05 12:38:04 | 2026-09-05 19:38:04 | Root and metadata blackbox probe restoration began; both targets were uniquely discovered and healthy. |
| 2026-09-05 12:47:21 | 2026-09-05 19:47:21 | The application-metrics restoration procedure began. The authenticated baseline and bounded canary checks then preceded enabling periodic scraping. |
| 2026-09-05 12:53:19 | 2026-09-05 19:53:19 | A healthy application scrape completed the initial bounded six-minute restoration soak; required longer-duration exit criteria remained open. |
## Technical root cause
### Confirmed mechanism
The confirmed root cause was unbounded application metric label cardinality, not a Prometheus
memory leak.
1. Deployed commit `e46277d` called bare `PrometheusMetrics(app)` in `api/__init__.py` and pinned
`prometheus_flask_exporter==0.23.2` and `prometheus-client==0.21.0` in
`config/requirements_relay.txt`. The exporter defaults (`export_defaults=True`,
`group_by="path"`) enabled its Flask request-latency histogram with `(method,path,status)`
identity. Each unique tuple exposed 17 series: 15 buckets plus `_count` and `_sum`.
2. Default Flask request instrumentation therefore retained request-controlled raw paths as labels.
Almost every observed path value during the buildup was an unmatched route: 1,659 of
1,679 at 23:30 UTC, 2,719 of 2,741 at 23:40 UTC, and 4,093 of 4,124 at 23:46:30 UTC.
3. Distinct path values rapidly multiplied the number of application series. Flask series grew
from 28,964 at 23:30 UTC to 70,529 at 23:46:30 UTC and peaked at 71,056 at 23:48 UTC.
4. Prometheus scraped 28,996 samples at 23:30 UTC, 47,050 at 23:40 UTC, and 71,088 at the
23:48 UTC peak. During the crash loop, exposition responses were approximately 7.79 MB and took
approximately 6–8 seconds. Historical Prometheus scrape duration rose from 3.102 seconds at
23:30 UTC to 6.150 seconds at 23:40 UTC and reached an observed maximum of 8.238 seconds.
5. Relay memory approached the container's 268,435,456-byte limit. The largest 30-second working
set sample was 247,996,416 bytes (approximately 92.4%); the largest RSS sample was 242,376,704
bytes. The samples need not capture the instantaneous allocation that crossed the hard limit.
6. Kubernetes OOM-killed the process. Its previous-state termination evidence, not the zero-valued
`container_oom_events` query, establishes the OOM.
7. Multiprocess metric state used pod-lifetime `/tmp` without startup cleanup. Application-container
restarts within the same pod could retain the accumulated metric database. The old files were
not directly inventoried, and scrape pause and pod replacement occurred together, so their
individual effects were not production A/B tested.
8. Production had one desired and available replica. Each OOM interval could therefore leave no
serving backend.
### Causal fingerprint
The tuple counts below are separate from the distinct-path figures in the timeline:
| UTC | `(method,path,status)` tuples | `17 × tuples` | Observed Flask series | Residual |
| --- | ---: | ---: | ---: | ---: |
| 23:30:00 | 1,703 | 28,951 | 28,964 | 13 |
| 23:40:00 | 2,765 | 47,005 | 47,018 | 13 |
| 23:46:30 | 4,148 | 70,516 | 70,529 | 13 |
| 23:48:00 | 4,179 | 71,043 | 71,056 | 13 |
Every checkpoint satisfies `flask_series = 17 × request_histogram_tuples + 13`. The increase was
exactly `2,476 × 17 = 42,092` series, equal to `71,056 − 28,964`. This is the causal fingerprint:
raw-path histogram cardinality accounts for all observed Flask-series growth, rather than a
generic Prometheus-server memory leak.
### Source hardening and deployed provenance
Source commit
[`a86fa1a`](https://github.com/futuroptimist/token.place/commit/a86fa1a47220023b7a236fefcb7f78c0c4487880),
merged to `main` by
[`e0e2685`](https://github.com/futuroptimist/token.place/commit/e0e2685062a1f4714dd6a10cda7586dd1534860a),
introduced `RELAY_METRICS_REGISTRY`, set `metrics_export_defaults=False`, normalized unmatched
routes through `_normalise_http_route()` to `other`, and added
`test_metrics_scrape_uses_bounded_relay_registry`.
PR [#1447](https://github.com/futuroptimist/token.place/pull/1447), merged to `main` as `e0e2685`
on July 13, implemented a dedicated `CollectorRegistry`, `metrics_export_defaults=False`,
`metrics_path=None`, a relay-owned bounded `/metrics`, and reviewed finite labels. Its negative
test proves unmatched paths expose neither raw paths nor default `flask_http_request*` families.
The deployed commit `e46277d` came from PR #1735's `release/relay-0.1.1` base
[`dc6ac09`](https://github.com/futuroptimist/token.place/commit/dc6ac09d7963d417ab6054b97c48e314a6494eef).
Repository ancestry confirms that `e46277d` is not a descendant of hardening merge `e0e2685`, and
its `api/__init__.py` still calls default `PrometheusMetrics(app)`. Thus the bounded implementation
existed on `main` but was omitted from the deployed release lineage. This is confirmed repository
evidence; this record does not speculate about why that release line was selected or imply a
rollout near incident onset.
On the divergent release line, PR [#1729](https://github.com/futuroptimist/token.place/pull/1729)
narrowly backported four gauges and bearer authentication while leaving the default exporter
enabled. PR [#1732](https://github.com/futuroptimist/token.place/pull/1732) fixed maintenance-gauge
PID labels while explicitly preserving the default registry and route. PR
[#1735](https://github.com/futuroptimist/token.place/pull/1735) changed build-info labels and
produced deployed `e46277d`. Release validation checked required metric presence and authentication
but lacked a negative/cardinality regression proving default Flask families and raw unmatched
paths were absent.
### Trigger classification and causal confidence
Aggregate evidence confirms **automated-style unmatched-path/404 probing** as the trigger class.
From 23:30 through 23:48 UTC, every one of the 2,476 new tuples was a 404: 2,469 were `GET 404` and
7 were `POST 404`. Of 2,998 incremental requests, 2,675 (89.23%) were 404s. The 2,476 new tuples
equaled 92.56% of incremental 404 requests, demonstrating extremely low path reuse. The singleton-
path ratio rose from 34.43% to 70.81%. Tuple growth accelerated from 106.2 per minute during
23:30–23:40 to 212.8 per minute during 23:40–23:46:30.
The actor, source network, external-versus-internal origin, and intent remain unknown. This record
does not characterize the traffic as a malicious attack or attribute it to a party. The traffic
exposed a latent defect; regardless of origin, request input must not create unbounded labels.
Confidence is separated as follows:
- **Confirmed root defect:** request-controlled raw paths became unbounded metric labels.
- **Confirmed trigger class:** rapid automated-style unmatched-path/404 probing.
- **Confirmed outcome:** Kubernetes OOM-killed the relay.
- **Strongly supported final pressure mechanism:** collection and serialization of approximately
71,088 samples into a roughly 7.79 MB, 6–8 second exposition while only approximately 19.49 MiB
of sampled cgroup headroom remained. No heap profile captured the exact final allocation.
- **Persistence factor:** pod-lifetime multiprocess metric state lacked startup cleanup. Old files
were not directly inventoried, and scrape pause plus pod replacement occurred together, so the
individual effects were not production A/B tested.
- **Impact amplifier:** one replica.
- **Systemic cause:** release-line safety parity and negative-test coverage failed.
### Deployment-change attribution and PR #1726
PR [#1726](https://github.com/futuroptimist/token.place/pull/1726) was unrelated to this outage. It
merged on 2026-09-02 at 03:53:30 UTC as an internal, non-runtime-wired Valkey
scheduler/reservation/enqueue slice. Although the PR contained 65 commits, production was running
`ghcr.io/futuroptimist/tokenplace-relay:sha-e46277d`. Commit
[`e46277d`](https://github.com/futuroptimist/token.place/commit/e46277daaeb76beeb9f2a2e9e265181287239b22)
was created on August 30 and predates the #1726 merge.
That deployed commit came from PR
[#1735](https://github.com/futuroptimist/token.place/pull/1735), whose relevant change was limited
to immutable build-info labels. No production rollout occurred near incident onset; the
ReplicaSet and pod were approximately three days old. Neither #1726's commits nor a same-time
deployment are attributed as the trigger.
## Contributing factors
- Implicit default per-path Flask instrumentation accepted effectively unbounded caller-controlled
label values in the deployed release instead of the bounded registry already present on `main`.
- The deployed release lineage omitted `main`'s normalization of unmatched/404 paths to `other`.
- Release/artifact provenance checks did not prevent deployment of a lineage missing the source-
level metrics hardening.
- Cardinality, scrape sample count, series additions, scrape duration, memory headroom, and restart
acceleration were not correlated early enough to prevent the OOM.
- The 256Mi hard limit left little headroom as metric state and scrape serialization grew. Raising
it alone would delay, not fix, an unbounded-cardinality failure.
- Multiprocess metric files shared general pod-level `/tmp` storage and were not safely cleared on
every application-container startup.
- The `emptyDir` lifetime was the pod lifetime, not the application-container lifetime.
- A single replica amplified every process crash into a possible total loss of serving capacity.
Adding replicas is not yet a safe standalone mitigation because authoritative relay correctness
state remains memory-backed.
- The ServiceMonitor's established 30-second scrape schedule repeatedly exercised the expensive
exporter. It had existed since August 27 and was not a newly activated incident-time change.
## Recovery and resolution
At 2026-09-03T01:07:50Z, the operator first verified the production context, workload, exact image,
replica count, memory limit, and scrape-target configuration. The operator then:
1. paused Prometheus discovery of only the application target by changing its selector to a
nonmatching value;
2. deleted only the affected pod so Kubernetes created a replacement with fresh pod-level
temporary storage; and
3. did not roll back the image or change the workload.
The replacement started at `2026-09-03T01:08:18Z`, reported Ready `True`, and had zero restarts
during mitigation verification.
This emergency action removed the accumulated pod-level metric files and stopped scheduled
scrapes from rebuilding or serializing the unsafe metric set. It restored application service but
was not the permanent fix. The later corrected-image deployment and deliberate scrape restoration
completed the operational recovery described below.
## Post-recovery verification
- The replacement pod was Ready `True` with zero restarts during verification.
- Public `/livez`, `/healthz`, and `/` requests returned HTTP 200.
- Application state was healthy.
- The deployed image and Deployment were unchanged by mitigation.
- token.place application scraping remained intentionally paused.
- The verification proves recovery of serving traffic, not that the cardinality defect has been
permanently corrected or that scraping was safe to restore at that earlier point.
## Recovery deployment and initial verification
Production Helm revision 7 was upgraded at `2026-09-05T19:10:05.335904906Z` to
`ghcr.io/futuroptimist/tokenplace-relay:sha-6c39adc`, the combined recovery image supplied by the
bounded-metrics backport in PR [#1782](https://github.com/futuroptimist/token.place/pull/1782) and
the public-information exemption backport in PR
[#1789](https://github.com/futuroptimist/token.place/pull/1789). The observed artifact identity
was:
- OCI index digest
`sha256:543fde33aff45253630090b52d16163e3586da12c973f5c4a658ddc8927d0a68`;
- source commit `6c39adc64e7bed4f85d07164aa2860e637919ca9`;
- immutable public build ref `sha-6c39adc`; and
- public semantic version `0.1.1`.
The new pod was created at `2026-09-05T19:10:09Z` and started at
`2026-09-05T19:10:13Z`. Production had one ready replica, zero restarts, and no termination reason.
Cutover verification began at `2026-09-05T19:32:18Z`: the digest and immutable ref matched, one
compute registration and continuing successful polling were observed, and two accepted requests,
two accepted responses, and two successful retrievals completed. All public health and identity
endpoints returned HTTP 200, no HTTP 5xx occurred, and a bounded two-minute soak held relay memory
between 63,971,328 and 64,786,432 bytes against the 268,435,456-byte limit.
Public-information monitoring restoration began at `2026-09-05T19:38:04Z`. The root and
`/api/v1/meta` blackbox probes were restored to `release=kube-prometheus-stack`, alongside the
continuously active `/livez` and `/healthz` probes. Prometheus uniquely discovered both restored
targets with no last error. It recorded successful root and metadata scrapes at
`2026-09-05T19:41:51.950530221Z` and `2026-09-05T19:42:08.689609225Z`, respectively. Each target
produced three samples in the final three-minute window with `min_over_time(probe_success[3m])=1`.
There were no HTTP 429 or 5xx responses; compute polling continued, the pod stayed ready with zero
restarts, and memory remained approximately 64–65 MiB.
Application-metrics restoration began at `2026-09-05T19:47:21Z`. Before periodic scraping was
enabled, an authenticated manual scrape returned HTTP 200, 35,733 bytes, and 227 samples. Twenty
deterministic unmatched-path canaries all returned HTTP 404; the following authenticated scrape
returned 35,749 bytes and 227 samples, with no canary path and no `pid` label. Each maintenance
metric (`tokenplace_build_info`, `tokenplace_compute_nodes_healthy`,
`tokenplace_compute_nodes_registered`, and `tokenplace_instrumentation_up`) had exactly one series.
The token.place ServiceMonitor was then restored to `release=kube-prometheus-stack`. Prometheus
discovered exactly one healthy application target, with no last error, in scrape pool
`serviceMonitor/tokenplace/tokenplace/0`. Its final successful scrape was recorded at
`2026-09-05T19:53:19.538689743Z`. The final three-minute window contained six successful `up`
samples with `min_over_time(up[3m])=1`, `scrape_samples_scraped=227`,
`scrape_duration_seconds=0.011584181`, `tokenplace_instrumentation_up=1`, and zero raw canary
series. A final authenticated payload contained 35,751 bytes and 227 samples, with zero canary and
zero `pid`-label occurrences.
During the bounded six-minute restoration soak, memory stayed between 65,101,824 and 65,425,408
bytes against the 268,435,456-byte limit, compute polls increased from 222 to 260, the pod remained
ready with zero restarts, and HTTP 429 and 5xx counts both remained zero. At
`2026-09-05T19:53:19Z`, the bounded exporter was deployed, authenticated application scraping was
restored, and **no intentionally disabled monitoring functionality remained**. This is the
operator-supplied operational-resolution boundary, but it is only bounded production observation:
20 canaries and six minutes do not independently document the required thousands-path regression,
same-pod restart-cleanup verification, operator-reported staging soak, or 24-hour stability windows
below.
PR [#1782](https://github.com/futuroptimist/token.place/pull/1782) provides repository regression
coverage for bounded metrics and restart cleanup. That automated evidence is distinct from the
operator-reported completed production-equivalent staging qualification and from the bounded
production observations above; this public summary does not reproduce the complete staging
evidence, and the 20 production canaries are not represented as a thousands-path staging test.
## What went well
- Triage verified the exact context, namespace, Deployment, image, replica count, memory limit, and
scrape target before making a narrowly scoped mitigation.
- Kubernetes termination state provided authoritative OOM evidence even though a related
Prometheus OOM query remained zero.
- Historical 30-second metrics and sanitized aggregate application-log measurements allowed
cardinality, scrape cost, memory pressure, and restarts to be aligned without publishing raw
paths or sensitive payloads.
- Pausing only the affected target and replacing only the affected pod restored service without an
image rollback or Deployment change.
- Public health checks confirmed that the replacement served traffic.
## What went poorly
- Caller-controlled unmatched paths could produce new metric series without a fixed bound.
- Metrics collection competed for memory with the relay and could serialize an approximately
7.79 MB response every 30 seconds under the incident state.
- Container restart did not clear multiprocess metric files, making automatic recovery ineffective.
- A one-replica deployment turned process instability into intermittent production unavailability.
- Detection did not warn on the combined cardinality, scrape-duration, memory-headroom, and restart
trend before the hard OOM limit was crossed.
- Application scraping had to be paused to recover safely, leaving production telemetry degraded.
- Evidence retention could not identify the traffic source or quantify customer request failures
and possible active in-memory work loss.
## Corrective actions
Corrective actions are tracked in the linked issues below. Creating or closing a tracker does not
by itself change an action's implementation, deployment, or restoration status. This documentation
change closes no issues and does not satisfy broader exit criteria that remain undocumented.
| Priority | Type | Action | Rationale | Owner | Status | Tracker | Verification or exit criterion |
| --- | --- | --- | --- | --- | --- | --- | --- |
| P0 | Prevent | Replace raw-path Flask grouping with bounded route-template or endpoint labels. | Removes caller control of label cardinality. | Unassigned | Deployed; trackers closed 2026-09-06; broader evidence limits retained | [token.place #1765](https://github.com/futuroptimist/token.place/issues/1765)
[sugarkube #2775](https://github.com/futuroptimist/sugarkube/issues/2775) | The corrected image identity is verified and a fixed allowlist of route labels is demonstrated under adversarial traffic. |
| P0 | Prevent | Collapse every unmatched/404 route to one fixed label; prohibit query strings, request IDs, model names, keys, tokens, and arbitrary path segments from labels. | One unknown route class must remain one series class and must not expose sensitive values. | Unassigned | Deployed; tracker closed 2026-09-06; broader evidence limits retained | [token.place #1765](https://github.com/futuroptimist/token.place/issues/1765) | Thousands of distinct unknown URLs yield the same bounded labels and no sensitive strings in exposition. |
| P0 | Prevent | Prefer a small explicitly registered metric set over implicit default per-path instrumentation. | Makes the exported contract reviewable and bounded. | Unassigned | Deployed; tracker closed 2026-09-06 | [token.place #1765](https://github.com/futuroptimist/token.place/issues/1765) | The deployed corrected image exports documented finite metric names and label domains. |
| P0 | Prevent | Add a regression/load test with thousands of unique unmatched paths and fixed budgets for series, samples, response size, scrape duration, and memory. | Reproduces the trigger class and prevents recurrence. | Unassigned | Repository regression and staging qualification completed; trackers closed 2026-09-06; complete public evidence not reproduced | [token.place #1765](https://github.com/futuroptimist/token.place/issues/1765)
[sugarkube #2774](https://github.com/futuroptimist/sugarkube/issues/2774) | The test passes explicit reviewed budgets and fails the prior unbounded behavior. |
| P0 | Prevent | Move multiprocess metrics to a dedicated directory and clear it safely on every application-container startup before Gunicorn launches. | A container restart must not inherit stale metric files; pod deletion must not be the cleanup mechanism. | Unassigned | Implemented and regression-covered by #1782; tracker closed 2026-09-06 | [token.place #1765](https://github.com/futuroptimist/token.place/issues/1765) | A container restart test proves the directory starts clean without deleting the pod. |
| P0 | Prevent | Validate Prometheus multiprocess worker cleanup. | Dead-worker files and series must not accumulate across worker lifecycles. | Unassigned | Repository regression covered by #1782; tracker closed 2026-09-06; broader qualification pending | [token.place #1765](https://github.com/futuroptimist/token.place/issues/1765) | Repeated worker start/exit testing leaves a bounded, correct exposition. |
| P1 | Prevent | Consider a bounded edge rate limit or scanner control for unmatched paths. | Reduces abusive load as defense in depth but cannot replace bounded labels. | Unassigned | Proposed | [sugarkube #2780](https://github.com/futuroptimist/sugarkube/issues/2780) | Legitimate routes remain usable and randomized-path traffic is bounded; exporter tests still pass without this control. |
| P1 | Prevent | Reassess the 256Mi memory limit only after measuring the corrected exporter. | Measured headroom is useful, but a temporary increase is not a root-cause fix. | Unassigned | Proposed | [sugarkube #2781](https://github.com/futuroptimist/sugarkube/issues/2781) | A sustained corrected-exporter test supports a documented limit and safety margin. |
| P0 | Detect | Alert on relay working-set-to-limit ratios at warning and critical thresholds. | Provides actionable headroom before kernel enforcement. | Unassigned | Proposed | [Threshold measurement: sugarkube #2781](https://github.com/futuroptimist/sugarkube/issues/2781)
[Alert implementation: sugarkube #2405](https://github.com/futuroptimist/sugarkube/issues/2405) | Controlled threshold crossing fires and resolves both alert levels. |
| P0 | Detect | Alert directly on `OOMKilled` state and restart acceleration. | Avoids waiting for only the scrape target to fail. | Unassigned | Proposed | [Alert implementation: sugarkube #2405](https://github.com/futuroptimist/sugarkube/issues/2405) | Synthetic OOM-state and restart-rate inputs exercise the alert path. |
| P0 | Detect | Define per-target budgets and alerts for `scrape_samples_scraped`, `scrape_series_added`, `scrape_duration_seconds`, and distinct bounded route-label counts. | Detects exporter growth before memory exhaustion. | Unassigned | Proposed | [Restoration budgets and thresholds: sugarkube #2777](https://github.com/futuroptimist/sugarkube/issues/2777)
[Alert implementation: sugarkube #2405](https://github.com/futuroptimist/sugarkube/issues/2405) | Each budget has a documented threshold and tested alert. |
| P1 | Detect | Add a dashboard combining application cardinality, scrape sample count/size, scrape latency, memory headroom, and restarts. | Correlated signals shorten diagnosis. | Unassigned | Proposed | [Existing observability work](https://github.com/futuroptimist/sugarkube/issues/2405) | Dashboard panels populate from a staging cardinality exercise. |
| P0 | Detect | Add a release/staging gate that rejects linear series growth from unique unknown paths. | Stops reintroduction before production. | Unassigned | Proposed | [sugarkube #2774](https://github.com/futuroptimist/sugarkube/issues/2774) | Gate fails an intentionally unbounded fixture and passes bounded instrumentation. |
| P0 | Detect | Add a release/artifact provenance gate that requires either verified `e0e2685` ancestry or an explicitly reviewed and tested behavior-equivalent backport for the focused release-line hotfix. | Prevents a divergent release lineage from omitting required hardening while retaining the documented manual-hotfix strategy. | Unassigned | Proposed | [token.place #1770](https://github.com/futuroptimist/token.place/issues/1770) | Promotion verifies `e0e2685` ancestry or an explicitly reviewed equivalent backport, passes the bounded-registry negative/cardinality tests, and records the image and source commit identities and qualification path. |
| P1 | Detect | Retain privacy-safe aggregate access evidence long enough to classify future triggers without raw credentials, query strings, encrypted payloads, or sensitive paths. | Improves attribution confidence without weakening privacy. | Unassigned | Proposed | [sugarkube #2782](https://github.com/futuroptimist/sugarkube/issues/2782) | Retention and redaction review proves only bounded aggregates are stored. |
| P0 | Mitigate | Write a metrics-induced OOM-loop runbook: validate context/target, pause only its ServiceMonitor, replace the pod, verify public health, and keep scraping paused until exit criteria pass. | Makes the safe, narrow recovery repeatable. | Unassigned | Proposed | [sugarkube #2779](https://github.com/futuroptimist/sugarkube/issues/2779) | A non-production exercise completes with no unrelated target mutation. |
| P1 | Mitigate | Add a bounded emergency setting that disables expensive application metrics while retaining liveness, readiness, and minimal operational metrics. | Preserves minimum observability without exercising unsafe exposition. | Unassigned | Proposed | [token.place #1773](https://github.com/futuroptimist/token.place/issues/1773) | Exercise shows minimal metrics and health remain available with bounded resource use. |
| P0 | Mitigate | Document that container restart does not necessarily clear a pod `emptyDir`; this incident required pod replacement. | Prevents ineffective restart loops. | Unassigned | Proposed | [sugarkube #2779](https://github.com/futuroptimist/sugarkube/issues/2779) | Runbook review and a pod-lifecycle test demonstrate the distinction. |
| P0 | Mitigate | Define a safe procedure to restore the ServiceMonitor label only after the corrected image is verified. | Prevents premature scrape restoration. | Unassigned | Proposed | [sugarkube #2777](https://github.com/futuroptimist/sugarkube/issues/2777) | Procedure includes all restoration gates and a rollback step. |
| P1 | Mitigate | Continue shared-state/HA work tracked by [#1569](https://github.com/futuroptimist/token.place/issues/1569), without treating replicas as safe standalone mitigation while authoritative state is memory-backed. | HA can reduce single-replica amplification only after correctness constraints are satisfied. | Unassigned | Proposed | [Existing non-incident HA work #1569](https://github.com/futuroptimist/token.place/issues/1569) | Shared-state correctness is proven before multi-replica availability is relied upon. |
| P0 | Mitigate | After the fix, run a staging soak with scraping enabled and adversarial unique-path traffic before production restoration. | Validates the whole scrape/traffic lifecycle. | Unassigned | Operator-reported complete before deployment; tracker closed 2026-09-06; complete public evidence not reproduced | [sugarkube #2774](https://github.com/futuroptimist/sugarkube/issues/2774) | Sustained soak passes every restoration exit criterion. |
The recovery sequence is tracked explicitly: the bounded backport in
[token.place #1765](https://github.com/futuroptimist/token.place/issues/1765), staging qualification
in [sugarkube #2774](https://github.com/futuroptimist/sugarkube/issues/2774), and exact-image
deployment in [sugarkube #2775](https://github.com/futuroptimist/sugarkube/issues/2775) led to
application-metrics restoration tracked by
[sugarkube #2777](https://github.com/futuroptimist/sugarkube/issues/2777), after
[sugarkube #2776](https://github.com/futuroptimist/sugarkube/issues/2776) restored both
public-information probes. The operator reported qualification of the combined recovery image in
staging complete before its production deployment. Service and observability restoration support
the operational classification, while this public summary does not reproduce the complete staging
qualification evidence; independent evidence review and the 24-hour stability criteria remain
undocumented even though the completed-work trackers closed. Repository restart-cleanup coverage in
#1782 is not a claim that every production or staging acceptance criterion passed.
The fifteen canonical action trackers are token.place #1765, #1766, and #1770–#1773, plus
sugarkube #2774–#2782. As of 2026-09-06, #1765, #1766, #2774, and #2775 are independently closed;
the remaining eleven (#1770–#1773 and #2776–#2782) are open. Links to token.place
[#1569](https://github.com/futuroptimist/token.place/issues/1569) and sugarkube
[#2405](https://github.com/futuroptimist/sugarkube/issues/2405) are contextual trackers outside
that canonical fifteen-item set; every tracker link and owner in the tables remains unchanged.
### Prometheus restoration path
The recommended near-term path is a focused hotfix based on the current `release/relay-0.1.1`
line, unless a newer main-derived release has independently completed every existing staging and
production-promotion gate. Urgent observability restoration must not be coupled to an otherwise
unqualified broad `main` rollout. The release hotfix must manually port the bounded behavior from
#1447 rather than blindly cherry-pick its historical diff. It must:
- disable prometheus-flask-exporter defaults and its automatic `/metrics` route;
- use a dedicated registry and relay-owned authenticated `/metrics`;
- preserve the required build, instrumentation, and node gauges;
- expose only reviewed finite `tokenplace_*` label domains;
- prove that no default `flask_http_request*` family, raw path, query string, request ID,
credential, ciphertext, or arbitrary value appears;
- use a dedicated exact multiprocess directory and clear/recreate only that directory before
Gunicorn starts, or formally remove multiprocess mode if the release is locked to one worker;
and
- add a same-pod application-container restart test proving stale metric files are not inherited.
Before production scraping is restored, an immutable hotfix image must pass staging with the
production-equivalent one-worker, 256Mi, and 30-second scrape settings. The operator reported this
qualification complete for the combined recovery image before deployment, although this public
summary does not reproduce its complete evidence. The test must send thousands
of unique unknown paths while continuously scraping and verify bounded series/sample counts,
response size, scrape latency, RSS/working-set headroom, zero OOMs/restarts, clean restart behavior,
exact image identity, public health, and API-v1 compatibility.
Production must keep application scraping paused through corrected-image deployment. Because relay
authority remains memory-backed and the workload uses a single replica with Recreate semantics,
the existing controlled quiescence and rollout procedure is required. Operators must verify health
and build identity before deliberately restoring only the exact target discovery selector, then
observe a defined stability window with explicit rollback thresholds. If cardinality, scrape cost,
memory, or restarts regress, pause only that target again. Scraping must never be restored against
`e46277d`; a memory-limit increase, edge filtering, or extra replicas alone is not resolution.
These retained requirements continue to govern full qualification and issue closeout. The
identity-verified recovery deployment and bounded restoration soak ending at
`2026-09-05T19:53:19Z` support the operator's **Resolved operationally** classification. The staging
qualification is operator-reported complete, while independent review of its evidence and the
other unverified criteria below remain pending.
### Required restoration exit criteria
For full historical qualification and issue closeout, all of the following must be demonstrated:
- unmatched paths map to a bounded label set;
- thousands of unique paths do not grow Prometheus series linearly;
- `/metrics` sample count, response size, and latency remain within explicit reviewed budgets;
- relay working set remains comfortably below the configured memory limit during a sustained
scrape-and-traffic test;
- an application-container restart cannot inherit stale multiprocess metric files;
- the corrected image is deployed and its identity is verified;
- public health and application functionality remain healthy; and
- the ServiceMonitor is restored deliberately and observed through a defined stability window.
## Post-incident closeout
The availability impact ended when the replacement pod became healthy at
2026-09-03T01:08:18Z. Current closeout state is:
- **Application:** healthy after mitigation.
- **Telemetry:** all intentionally paused monitoring restored; application scraping is healthy.
- **Incident:** Resolved operationally at `2026-09-05T19:53:19Z`; broader historical qualification
evidence remains undocumented.
- **Runtime/deployment remediation:** Helm revision 7 runs the identity-verified combined recovery
image with the bounded registry backport, and the restored production scrape remained bounded.
- **Closeout:** four completed-work trackers closed independently on 2026-09-06; eleven canonical
corrective and preventive issues remain open, and this documentation change closes none.
Disabling scraping, replacing a pod, filtering traffic, raising memory, or adding replicas alone
do not constitute resolution; the corrected deployment plus initial monitored restoration are
recovery evidence but do not yet meet every closeout gate. Issue
#1569 concerns separate shared-state/HA resilience work and did not cause this incident.
## Evidence gaps and unknowns
- The historical evidence window begins at 23:20 UTC, so the exact first unmatched request that
began the buildup is unknown.
- Raw historical request paths and exact source addresses are unavailable or intentionally
excluded from the public record. The traffic source cannot be attributed.
- Exact customer request failures and any active in-memory work losses were not measured. The
record identifies risk, not proven customer data loss.
- Exact PagerDuty trigger and acknowledgement timestamps are unavailable in the sanitized evidence.
- Direct pre-mitigation `kubectl top` and cgroup capture failed because the old container was
already unavailable.
- `container_oom_events` remained zero. This is not evidence against the authoritative Kubernetes
`lastState.terminated.reason=OOMKilled` record.
- `scrape_body_bytes` remained zero. It is not evidence of an empty response; response-size claims
use sanitized application access-log measurements instead.
- Thirty-second memory samples may have missed the instantaneous allocation that crossed 256Mi.
- Raw paths, query strings, request IDs, credentials, tokens, ciphertext, prompts, responses, tool
data, arbitrary diagnostic payloads, source addresses, private evidence paths, screenshots, and
raw log archives are intentionally excluded.
## Related incident
Stable process uptime after this incident's mitigation exposed a distinct, pre-existing
[public-information rate-limit defect on September 3](2026-09-03-production-public-information-rate-limit-exhaustion.md).
Application-metrics scraping remained paused throughout the September 3 failure and initial
mitigation period. Scraping was subsequently restored before operational resolution. The later quota exhaustion
does not alter this incident's metrics-cardinality root cause; both records instead identify a
shared release-line parity and production-qualification weakness.
## Verification commands and public references
Repository commands used to validate this documentation change:
```bash
python -m json.tool outages/2026-09-02-production-relay-metrics-cardinality-oom.json >/dev/null
python -c 'import json; import jsonschema; jsonschema.validate(json.load(open("outages/2026-09-02-production-relay-metrics-cardinality-oom.json")), json.load(open("outages/schema.json")))'
pre-commit run --all-files
git diff --check
detect-secrets scan $(git diff --cached --name-only)
```
Public references:
- [Deployed commit `e46277d`](https://github.com/futuroptimist/token.place/commit/e46277daaeb76beeb9f2a2e9e265181287239b22)
- [Bounded-registry source commit `a86fa1a`](https://github.com/futuroptimist/token.place/commit/a86fa1a47220023b7a236fefcb7f78c0c4487880)
- [`main` hardening merge `e0e2685`](https://github.com/futuroptimist/token.place/commit/e0e2685062a1f4714dd6a10cda7586dd1534860a)
- [`release/relay-0.1.1` base `dc6ac09`](https://github.com/futuroptimist/token.place/commit/dc6ac09d7963d417ab6054b97c48e314a6494eef)
- [PR #1735: build-info label correction](https://github.com/futuroptimist/token.place/pull/1735)
- [PR #1732: maintenance-gauge PID-label correction](https://github.com/futuroptimist/token.place/pull/1732)
- [PR #1729: release-line metrics backport](https://github.com/futuroptimist/token.place/pull/1729)
- [PR #1447: bounded relay metrics registry](https://github.com/futuroptimist/token.place/pull/1447)
- [PR #1726: unrelated internal Valkey slice](https://github.com/futuroptimist/token.place/pull/1726)
- [Issue #1569: separate shared-state/HA resilience work](https://github.com/futuroptimist/token.place/issues/1569)
- [Deployed `api/__init__.py`](https://github.com/futuroptimist/token.place/blob/e46277daaeb76beeb9f2a2e9e265181287239b22/api/__init__.py)
- [Deployed relay requirement pins](https://github.com/futuroptimist/token.place/blob/e46277daaeb76beeb9f2a2e9e265181287239b22/config/requirements_relay.txt)
- [prometheus-flask-exporter 0.23.2 source and defaults](https://github.com/rycus86/prometheus_flask_exporter/blob/0.23.2/prometheus_flask_exporter/__init__.py)
- [prometheus-client 0.21.0 multiprocess requirements](https://github.com/prometheus/client_python/blob/v0.21.0/docs/content/multiprocess/_index.md)
- [Kubernetes `emptyDir` documentation](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir)
This public record uses sanitized aggregates and durable repository references. It does not publish
private evidence locations or raw diagnostic artifacts.