Default GET /metrics is Prometheus text; JSON stays at /metrics.json and ?format=json. :8443 needs the metrics caller bearer; loopback stays open. Restricted callers have no per-caller series and no tavily:restricted gauge.
254 lines
11 KiB
Markdown
254 lines
11 KiB
Markdown
# Sift P2.7 — Prometheus `/metrics`
|
|
|
|
Drafted 2026-09-23 by titan. Reviewer: Claude. Implementer: titan.
|
|
Orion pin: straylight, after Claude approves the code.
|
|
Kyle decided (2026-09-23, via Claude): aggregated restricted metrics;
|
|
bearer on `:8443`; loopback open. Claude: drop `search_rerank_fallback`
|
|
from Prometheus; export `sift_self_ip_failures_total`.
|
|
Scrape target: `:8443` with `bearer_token_file`.
|
|
|
|
Builds on live P1/P2/P3 (`e62cfc8`). No nixos edit from titan.
|
|
|
|
## 1. Scope and exit criteria
|
|
|
|
In: Prometheus text on `GET /metrics`; keep today's JSON; the series in §4;
|
|
duration histograms for fetch, digest, and search; scrape-token auth;
|
|
restricted aggregation (no per-caller series, no `tavily:restricted` gauge).
|
|
|
|
Out: Grafana; a new listen address or firewall (straylight's metrics-plan /
|
|
nixos); alert rules; PLAN's "bot-blocked per host" series; P2 §6 recover /
|
|
token-ratio / Honcho-context series; Go/process collectors;
|
|
`search_rerank_fallback` on Prometheus (stays JSON-only).
|
|
|
|
**Exit criteria** (evidence in this thread):
|
|
|
|
1. `make test lint` green (new tests in §7). `make contract` unchanged.
|
|
2. `GET /metrics` is Prometheus text 0.0.4 (loopback, or `:8443` with the
|
|
`metrics` bearer).
|
|
3. `GET /metrics.json` and `GET /metrics?format=json` still return today's
|
|
JSON object (existing keys, same types), including `tavily:restricted`
|
|
and per-caller maps. `TestMetricsCacheHitMiss` uses `/metrics.json`.
|
|
4. After one fleet search, one restricted search, and one digest-fallback,
|
|
the Prometheus body has the series in §4 — **no** query text, URL,
|
|
hostname, or IP, **no** `caller="<restricted name>"`, **no**
|
|
`tavily:restricted`.
|
|
5. Non-loopback `/metrics` without the metrics bearer is 401; a fetch
|
|
caller bearer is 403; the metrics caller cannot fetch or search (403).
|
|
Loopback with no proxy headers stays open.
|
|
|
|
## 2. Current `/metrics` (verified in tree, not assumed)
|
|
|
|
`internal/api/api.go` `GET /metrics` is unauthenticated JSON:
|
|
|
|
```json
|
|
{"requests": N, "blocks": N, "scrapes": N, "self_ip_failures": N,
|
|
"search_queries": {caller: {source: n}},
|
|
"search_credits": {caller: {source: n}},
|
|
"search_errors": {source: {kind: n}},
|
|
"search_cache_hit": N, "search_cache_miss": N,
|
|
"search_rerank_fallback": N,
|
|
"search_daily_credits": {source: n},
|
|
"search_capped": {source: n}}
|
|
```
|
|
|
|
Digest `Engine.Calls` / `Fallback`, `digest.Gate` inflight/waiters, and
|
|
`dcache` hit/miss exist in process memory and were not on JSON. P2.7
|
|
exports them (Prometheus + additive JSON fields).
|
|
|
|
Search `addErr` uses the warning tail as `kind`. JSON keeps that map.
|
|
Prometheus does not export `search_errors` (the tail is not a closed set).
|
|
|
|
Listener stays `127.0.0.1:8890` + existing `tailscale serve` HTTPS `:8443`.
|
|
|
|
## 3. Wire format
|
|
|
|
| Request | Response |
|
|
|---|---|
|
|
| `GET /metrics` | Prometheus text 0.0.4, `Content-Type: text/plain; version=0.0.4; charset=utf-8` |
|
|
| `GET /metrics.json` | today's JSON, `Content-Type: application/json` |
|
|
| `GET /metrics?format=json` | same JSON as `/metrics.json` |
|
|
|
|
`format` values other than `json` (including absent) → Prometheus text.
|
|
Do not count scrapes of `/metrics`, `/metrics.json`, or `/healthz` in
|
|
`sift_requests_total`.
|
|
|
|
Private `prometheus.Registry` with only the `sift_*` series below. No
|
|
client_golang Go/process collectors.
|
|
|
|
Dep: `github.com/prometheus/client_golang` (vendored).
|
|
|
|
## 3a. Auth (Kyle, 2026-09-23)
|
|
|
|
- **Loopback stays open.** `RemoteAddr` is loopback **and** there is no
|
|
`X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` /
|
|
`Tailscale-User-Login` / `Tailscale-Headers-Info` **and** `Host` is
|
|
loopback or `localhost`. This is `curl http://127.0.0.1:8890/metrics`.
|
|
`tailscale serve` on `:8443` sets forwarded / Tailscale headers and a
|
|
non-loopback Host, so it is not treated as loopback even though the
|
|
upstream is 127.0.0.1:8890.
|
|
- **`:8443` requires a bearer.** Dedicated `metrics` caller in `keys.toml`:
|
|
`name = "metrics"`, empty workspace, no fetch or search rights.
|
|
`sift keys add --caller metrics` forces `workspace = ""`.
|
|
- Missing/unknown key → 401. A fetch/search caller's key → 403. The
|
|
metrics caller on `/v1/fetch`, `/v2/scrape`, `/v1/search`, `/v2/search`
|
|
→ 403 `unauthorized`.
|
|
- `/healthz` stays unauthenticated.
|
|
- Serve start: WARN if keys.toml has no `metrics` caller (loopback still
|
|
works; `:8443` scrapes will 401). WARN if a `metrics` caller somehow
|
|
has a workspace (still treated as metrics-only). `CheckMappings` skips
|
|
the metrics caller so an empty workspace is not an ERROR.
|
|
|
|
Straylight's scrape config: `https://orion.scylla-hammerhead.ts.net:8443/metrics`
|
|
with `bearer_token_file`. Generating the key is on-box (same as other
|
|
callers); do not print it.
|
|
|
|
## 4. Series
|
|
|
|
Prefix `sift_`. Counters end `_total`. Histograms are seconds.
|
|
|
|
**Requests**
|
|
|
|
- `sift_requests_total{endpoint,code}` counter — `/v1/fetch`, `/v2/scrape`,
|
|
`/v1/search`, `/v2/search`. `code` is the HTTP status as a string.
|
|
Increment once per finished handler, including 401/403.
|
|
|
|
**Guard / self-IP**
|
|
|
|
- `sift_guard_blocks_total` counter — same events as today's `blocks`.
|
|
No host, URL, or reason-with-host label.
|
|
- `sift_self_ip_failures_total` counter — `selfip.Discoverer.Failures`.
|
|
|
|
**Digest and Dixie gate** (`internal/digest`)
|
|
|
|
- `sift_digest_calls_total` counter — Dixie chat actually started
|
|
(`Engine.Calls`; cache hits do not increment).
|
|
- `sift_digest_fallbacks_total{reason}` counter — closed `reason`:
|
|
`queue_full`, `queue_wait`, `canceled`, `embed`, `dixie`, `timeout`,
|
|
`empty`, `unset`. Unknown → `other` (never the raw string).
|
|
- `sift_dixie_inflight` gauge — `Gate` slots in use.
|
|
- `sift_dixie_queue_waiters` gauge — waiters in the sift-side FIFO.
|
|
|
|
`Gate` grows `Snapshot() (inflight, waiters int)` under the existing mutex.
|
|
|
|
**Caches**
|
|
|
|
- `sift_cache_requests_total{cache,result}` counter — `cache` is `digest`,
|
|
`embed`, or `search`; `result` is `hit` or `miss`. Search uses the engine
|
|
counters already on JSON (`search_cache_hit` / `search_cache_miss`).
|
|
|
|
**Search cost**
|
|
|
|
Prometheus cannot mix labeled and unlabeled series under one name, so
|
|
totals and per-caller fleet counts are separate:
|
|
|
|
- `sift_search_queries_total{source}` — **all** callers, including
|
|
restricted. No `caller` label.
|
|
- `sift_search_queries_caller_total{caller,source}` — **fleet only**.
|
|
`permit_restricted` callers are omitted.
|
|
- `sift_search_credits_total{source}` — all callers.
|
|
- `sift_search_credits_caller_total{caller,source}` — fleet only.
|
|
- `sift_search_daily_credits{source}` gauge — current UTC-day total.
|
|
**No `tavily:restricted` (or any `*:restricted`) source.** That partition
|
|
stays JSON-only.
|
|
- `sift_search_capped_total{source}` — same source filter as daily credits.
|
|
|
|
`source` values are configured names (`tavily`, `brave`, `searxng`, …).
|
|
No query, host, or URL.
|
|
|
|
**Histograms** (no `caller`; include restricted traffic)
|
|
|
|
- `sift_request_duration_seconds{op}` — `op` is `fetch`, `digest`, or
|
|
`search`. One HistogramVec; buckets are the union
|
|
`0.1, 0.5, 1, 2, 5, 10, 15, 20, 30, 45` (HistogramVec cannot vary
|
|
buckets by label).
|
|
- `fetch`: wall time of `/v1/fetch` and `/v2/scrape`.
|
|
- `digest`: `digest.Result.DigestMS` on digest attempts; skip cache hits.
|
|
- `search`: wall time of `/v1/search` and `/v2/search`.
|
|
|
|
**JSON additive fields** (same keys as today plus):
|
|
|
|
- `digest_calls`, `digest_fallbacks` (map reason→n), `dixie_inflight`,
|
|
`dixie_queue_waiters`
|
|
- `dcache_digest_hit`, `dcache_digest_miss`, `dcache_embed_hit`,
|
|
`dcache_embed_miss`
|
|
|
|
Existing JSON keys stay, including `search_rerank_fallback`,
|
|
`search_errors`, per-caller maps, and `tavily:restricted`.
|
|
|
|
## 5. Labels and redaction
|
|
|
|
`/metrics` is a second log (same isolation as restricted slog).
|
|
|
|
- **`caller` only on `*_caller_total` count series**, and only for
|
|
callers **not** in `permit_restricted`. Value is `auth.Caller.Name`.
|
|
Restricted traffic is in the unlabeled `{source}` totals and in
|
|
histograms only.
|
|
- **No `host`, `url`, `query`, `q`, `final_url`, `err`, or `msg` labels.**
|
|
- **`endpoint`**: the four API routes. **`code`**: three-digit status.
|
|
- **`reason` / `cache` / `result` / `op` / `source`**: closed sets from §4.
|
|
- **No `search_errors` on Prometheus.**
|
|
|
|
A test dumps every metric name, label name, and label value from a
|
|
handler that ran a restricted search + a blocked fetch, and asserts the
|
|
query, the URL, the hostname/IP, and the restricted caller name appear
|
|
**nowhere** as a `caller` label (the restricted name must not appear as
|
|
any label value). The query/URL/host/IP appear nowhere in the Prometheus
|
|
body, including HELP text.
|
|
|
|
## 6. Code layout
|
|
|
|
```
|
|
internal/metrics Registry, sift_* defs, loopback predicate, Gather
|
|
internal/digest Gate.Snapshot; Fallback/Calls already there
|
|
internal/auth NameMetrics; Add() clears workspace; MetricsOnly()
|
|
internal/api /metrics negotiates format + auth; /metrics.json
|
|
handlers observe duration + requests{endpoint,code}
|
|
metrics caller rejected on fetch/search
|
|
internal/fleetctx CheckMappings skips the metrics caller
|
|
cmd/sift serve WARN if no metrics caller
|
|
```
|
|
|
|
JSON continues to read the same atomics/maps the Prometheus collector
|
|
scrapes — one set of counters, two encodings.
|
|
|
|
Do not log `/metrics` scrapes at INFO.
|
|
|
|
## 7. Tests
|
|
|
|
- `GET /metrics` from loopback: Prometheus Content-Type; `expfmt` parse;
|
|
`HELP`/`TYPE` present for each series in §4.
|
|
- `GET /metrics.json` and `?format=json` from loopback: existing JSON keys.
|
|
`TestMetricsCacheHitMiss` pointed at `/metrics.json`.
|
|
- Non-loopback (or loopback + `X-Forwarded-For`): 401 without bearer; 403
|
|
with a fleet key; 200 with the metrics key.
|
|
- Metrics caller on `/v1/fetch` and `/v1/search`: 403.
|
|
- One fleet `/v1/search` then `sift_search_queries_caller_total{caller,source} == 1`
|
|
and `sift_search_queries_total{source} == 1`. Restricted search increments
|
|
only the unlabeled total; no `caller="<restricted>"`.
|
|
- Digest gate full → `sift_digest_fallbacks_total{reason=...}` and Dixie
|
|
gauges in range.
|
|
- Restricted + blocked fetch: Prometheus body has no query, URL, host, IP;
|
|
no restricted name as a label value; no `tavily:restricted`.
|
|
- Histogram: a timed fake fetch observes `_count`.
|
|
- Unknown fallback reason coerced to `other`.
|
|
- `/metrics` itself does not increment `sift_requests_total`.
|
|
|
|
No live orion scrape in CI.
|
|
|
|
## 8. Rollout
|
|
|
|
1. titan implements on `git.wntrmute.dev:kyle/sift`; evidence in this thread.
|
|
2. Claude reviews the code (auth predicate, restricted aggregation, tests).
|
|
3. straylight bumps the orion pin once (batch with other approved isolation
|
|
patches if any are waiting). Titan does not edit `~/src/nixos`.
|
|
4. On-box: `sift keys add --caller metrics --out <root-only file>`; place
|
|
the secret for VictoriaMetrics `bearer_token_file`; delete the temp copy.
|
|
Do not print the key.
|
|
5. straylight's metrics-plan scrapes `:8443/metrics` with that token.
|
|
|
|
## 9. Open questions
|
|
|
|
None remaining on this cut. Scrape URL is `:8443` with a bearer (Kyle /
|
|
Claude). `self_ip_failures` is on Prometheus; `search_rerank_fallback` is
|
|
not.
|