Running Indexterity - FullmetalBober/Indexterity GitHub Wiki
Running Indexterity
Everything about working on this and operating it: the development loop, how a release is cut, the images and the three topologies, the environment variables that matter and why, the metrics worth alerting on, and what error reporting does and does not send.
Moved out of the README so that page can be read in a minute. What the product decides is in Architecture, what holds it shut is in Security, and the scoped user it needs is in Connecting a cluster.
Every key, component and the things that turned out to be load-bearing: Architecture ยง14.
Develop
cp .env.example .env # then fill secrets
npm install
podman-compose up # postgres + api + web, hot reload
# or npm run up # the same, and recovers from stale container state
No mongod in the stack โ connect whichever cluster you are working against. Which throwaway you want depends on who has to reach it:
# the integration and e2e suites, which run on the HOST (they take MONGO_URL)
podman run -d --rm --name mongo-test -p 27017:27017 docker.io/library/mongo:8
# a cluster you connect IN the stack โ then use mongodb://mongo:27017
podman run -d --rm --name mongo --network mongo_optimizer_default \
--network-alias mongo docker.io/library/mongo:8
The second one has to be on the compose network: the api is a container, so
localhost is itself, and the host is only addressable as
host.containers.internal, which resolves into 169.254.0.0/16 โ a range
src/engine/net-guard.ts files as FORBIDDEN and never dials, even with
ALLOW_PRIVATE_CLUSTER_TARGETS, because the cloud metadata endpoint shares it.
Clusters registered before the demo mongod was dropped from compose already hold
mongodb://mongo:27017, and the api logs cluster <id> unreachable โ skipped
every tick until something answers to that name.
Anything topology-shaped needs a replica set, since $indexStats and
$collStats latencyStats are both per-member โ most easily several mongods as
several processes in one host-network container.
npm run build ยท npm run typecheck ยท npm run lint ยท npm run test
npm run db:generate ยท npm run db:migrate
npm run db:deploy -w @repo/api # production migrations, compiled migrator
npm run version:set 0.2.0 # workspaces + the chart + the lockfile
npm run version:check # assert every file that states it agrees
Releases are cut by hand, and the tag is the only trigger. Work integrates
on dev: npm run version:set X.Y.Z in a chore: release X.Y.Z commit writes
the version to the seven manifests, both chart fields and the eight places
package-lock.json states it. A dev โ main pull request promotes that, the
merge is tagged vX.Y.Z, and the GitHub Release is written from the commits it
carried. release.yml waits on the tag and nothing publishes from a branch, so
what a cluster pulls cannot move because something merged.
A release that adds a privilege to a provisioned role says so in its notes,
because the owners of every cluster connected before it are being asked for
something. The notes name the privilege, what it turns on, and that nothing breaks
without it (D184). version:set prints the list when it numbers the release, and
refuses a change in mongo/role.ts labelled for a later release than the one
being cut: everything in the tree ships in it.
version:check is what makes it safe rather than careful. One version lives in
seventeen places, and release.yml runs the check against the tag before it
builds or installs anything โ so a tag cannot claim a version the tree does not
carry. It earned that: 0.3.0, 0.4.0 and 0.5.0 each shipped with a stale
package-lock.json, a tree npm ci refuses, while the one command whose whole
job is reporting version drift said everything agreed (#186,
D78).
npm run up is a convenience, not a requirement โ podman-compose up works
directly. It recreates containers whose crun state a logout cleared (cannot open .../exec.fifo; named volumes untouched), and drops node_modules/.bin
from PATH, which matters only when compose is reached through npm โ
@vercel/nft installs an nft binary there and podman's network backend shells
out to nft meaning nftables.
npm run lint is Biome plus two checks Biome cannot express.
scripts/lint-tailwind.ts fails the build on a Tailwind arbitrary value that has
a canonical utility (w-[220px] is w-55); vendored components/ui is exempt.
scripts/lint-migrations.ts fails it on a migration that adds an enum value and
then uses it โ Postgres refuses that inside one transaction, and drizzle-kit runs
one file per transaction, so the ALTER TYPE has to commit on its own and the
backfill goes in the next migration. Both scripts verify their own cases on every
run, because the way a check like that fails is by matching nothing.
Migration installs two schemas
โ public for Drizzle and graphile_worker for the queue โ installed before the
api serves, because a request that queues a job would otherwise race a schema
that is not there yet.
Four test layers, currently 1,261 api unit, 548 web unit, 205 integration and 41 end-to-end.
| layer | what it needs | what only it catches |
|---|---|---|
npm run test |
nothing | the pure decision engine; components in jsdom with the api client mocked at ~/lib/api |
npm run test:int -w @repo/api |
migrated postgres + a mongod | CI runs it against 6.0, 7.0, 8.x and 9.0 โ they take different paths through the workload collector, and 9.0 keeps $queryStats' plan metrics somewhere else again |
npm run test:e2e |
both apps built | a real browser all the way to postgres and mongo, with no proxy in front, so the passthrough is the path under test. Two projects: chromium for the suite, and touch (a Pixel 7) for two specs that are only meaningful without a pointer |
deploy/kind-test.sh |
Kind | the chart actually installing, helm test, sign-up over cluster DNS; fails if any pod logged a warning. TOPOLOGY= runs the same assertions against each packaging, which is what holds them to being blind to it |
Preloading will tell you a loading change is fine when it is not.
defaultPreload: "intent" runs a route's loader 200ms after the pointer settles
on a link, so on a development machine a click usually lands on data that is
already there โ which is how a loader that blocked navigation for the length of
its reads survived as long as it did (D117). A tap is faster still: Link sends
hover and keyboard focus through one delayed timer and onTouchStart straight to
doPreload(), with no delay at all.
So do not judge a loader change by clicking it. Either watch it with the read
held open (page.route with a timeout, as navigation.spec.ts and
touch.spec.ts do), or throttle the network in devtools. The measurements behind
this are in #401.
The top ones are not decoration. The e2e suite found the api's session cookie
being percent-encoded a second time on its way through the web server โ both
sides correct on their own, the defect in the hand-off โ and the dashboard
drawing itself twice, which only a browser could see. The Kind run found that
the chart could not install at all, while helm lint passed throughout.
A superseded CI run is cancelled, not finished. Pushes to main and dev
are exempt โ those runs are the record of whether an integration branch is
sound. The release workflow has no such rule at all.
House rule: the api and the web app run clean. No errors and no warnings in server logs, build output, or the browser console. A warning is a defect โ fix
is deliberately not done: Security.
Deploy
The api boots from one file (D176, #567). On the 0.1 CPU of a free hosting
tier, the all-in-one container used to take 52โ54 s before its public
/api/health answered, and the host waits for that on every deploy and every
wake. A CPU profile put most of the api's boot in node's module loader:
resolving, stat-ing and reading swc's ~220 files and their package graph. So the
build bundles dist/main.js with esbuild after swc
(apps/api/scripts/bundle.mts). The container now answers in about 31 s at
0.1 CPU, and the api alone takes 2.4 s on one CPU, down from 5.3. Beyond that
it is CPU: at 0.5 CPU, the size of a small paid instance, the same container
answers in 7 s (11 s before the bundle).
Small images, and small on purpose. The api's images ship no node_modules at
all (D182): every entrypoint they run โ the api, and its migrate.js,
rotate-key.js and set-plan.js CLIs โ is one esbuild bundle, so the runtime
stage copies those four files, the api's package.json and its migrations, and
nothing else. The dashboard ships its self-contained .output the same way. The
base is most of what remains, so it is node:26-alpine (181 MB) instead of
node:26-slim (252 MB). Nothing shipped links against the C library โ no .node
file anywhere, and the tunnel binary is static โ so that is a base swap and not a
musl rebuild โ and mongodb+srv://, the one lookup that would care, is
tested on musl rather than assumed (D76). api 216 MB (was 510),
all-in-one 238 MB (was 714), web 193 MB (was 264):
docker build -f apps/api/Dockerfile -t indexterity-api .
docker build -f apps/web/Dockerfile -t indexterity-web .
There is a third, for hosts that run one container per service and have no pods
at all โ Fly, Render, Cloud Run, a single VM. Same builds, same artefacts, both
processes (three with the tunnel service) under a supervisor that is PID 1,
forwards SIGTERM to each and exits non-zero if any dies:
docker build -f deploy/all-in-one/Dockerfile -t indexterity-all-in-one .
docker run -p 3000:3000 -e DATABASE_URL=โฆ -e MASTER_KEY=โฆ indexterity-all-in-one
Port 3000 is the only one that has to be published: the dashboard answers /api
itself, so the browser gets everything from one origin. The Helm chart can deploy
this shape too โ topology: single-container.
Configuration
Every process validates its environment before it serves anything, against
one schema per process rather than reads scattered through the code. absent is fine, malformed is fatal: an unset optional knob takes its default, and a
malformed one is a boot failure naming the variable and what it expected. So
AUTH_RATE_LIMIT_MAX=2O (letter O) refuses to start instead of quietly reading
as 20, and a garbled TRUST_PROXY refuses instead of quietly meaning "no proxy
in front" โ which used to collapse every per-client rate limit into one shared
bucket.
Three schemas, because three validation surfaces are given different things
(apps/api/src/config/schema.ts):
| Process | Demands | Because |
|---|---|---|
migrate |
DATABASE_URL |
The pre-install Job talks to Postgres and exits |
worker |
+ MASTER_KEY |
The pipeline's subset, and what the rotate-key CLI validates โ it unseals stored credentials without serving HTTP |
api |
+ BETTER_AUTH_SECRET |
Only the api serves auth (and it validates the pipeline's variables too, since it runs the jobs) |
DATABASE_URL has to be the direct endpoint, not a transaction-pooled one โ
Neon's -pooler host, PgBouncer or Supavisor in transaction mode. LISTEN/NOTIFY
does not survive transaction pooling: the LISTEN is accepted and the notification
never arrives, because the listener and the notifier land on different backends.
Nothing errors, and the dashboard's SSE quietly never fires โ panels only refresh
on their own staleTime. Since a shape of failure that logs nothing is worse
than one that refuses, the api runs a NOTIFY round trip at boot (apps/api/src/db/notify-probe.ts: LISTEN a throwaway channel,
pg_notify it from a second connection, wait two seconds, close both) and refuse to
start when it does not come back โ three attempts, so a Postgres that was restarting
is a slow boot rather than a crashloop. The round trip is the test and the hostname
only a hint: measured on PgBouncer 1.25, pool_mode=transaction loses the
notification and pool_mode=session delivers it. A pooler buys this app nothing
either way โ it holds about 20 connections at its defaults
(D89).
A registered VPN peering is carried by a service beside the api
(wireguard-go and gvisor's netstack, apps/tunnel). It needs no capability and no
/dev/net/tun, so it changes nothing about what the pod is allowed to do โ it
runs under the same drop: ALL and nonroot the app container does, with a
read-only root filesystem, from a scratch image holding one static binary.
TUNNEL_PORT is the whole of the configuration, and the same variable turns
on both halves: the api looks for the service on that loopback port, and the
service binds it. Unset means the VPN feature is off โ the api says so and the
dashboard shows it as unavailable, which is a supported state rather than a
missing setting. There is no host and no secret because the service is always in
the api's own network namespace, so the peering's private key never crosses a
network.
Where that container comes from depends on the deployment: the all-in-one image's
supervisor starts it when TUNNEL_PORT is set, the chart adds it as a sidecar
when tunnel.enabled is on, and docker-compose runs it with
network_mode: service:api. On with no container in the pod is the one
misconfiguration to watch: every tunnel open then fails against a port nothing is
listening on, per tunnel rather than as a boot failure โ a tunnel nobody has used
yet cannot be checked at boot.
sslmode in DATABASE_URL is node-postgres's, not libpq's, and the two do
not mean the same thing. require and verify-ca both mean FULL verification
here โ hostname included โ and an absent sslmode means no TLS at all rather
than opportunistic TLS. Node deprecated that aliasing and now warns on every
connect, asking for the intent to be written down, so a production URL should say
sslmode=verify-full outright. require still works and still verifies, but it
warns each time and would silently become libpq's weaker meaning the day node
changes its default. uselibpqcompat=true&sslmode=require is the opposite
choice, spelled out โ encrypt without checking who answered.
The dashboard server has its own, built on @t3-oss/env-core
(apps/web/src/lib/env.ts), where the interesting part is the split: every
variable is a server one, so reading one in the browser throws rather than
returning undefined, and a value the browser genuinely needs has to be declared
with the VITE_ prefix that makes it public by construction. There are none.
The schema is also the source of truth for the four places a variable has to be
registered โ .env.example, docker-compose.yml, the test suites, and the Helm
chart. config/homes.test.ts walks it and holds all four to it, in both
directions: a required variable the chart never sets fails the test rather than
the deploy, and a variable the chart sets that no schema knows is a typo nothing
else would have caught.
One web image serves every environment โ API_URL and WEB_ORIGIN are read at
runtime, and nothing about the api's address is baked into the browser bundle.
The api runs the whole job pipeline itself (#232 retired the separate worker
process): no resident runner, no LISTEN โ a 30-second in-process tick claims
what became due and drains it with runOnce, so between ticks an idle api holds
nothing. Replicas are safe: passes are claimed per occurrence, so concurrent
tickers cannot double-dispatch. The interval still queries twice a minute โ a
database that suspends on idle wants the external clock below instead.
The clock outside (RUN_CRONJOB=false). The api always executes jobs; this
flag hands over the decision of when passes become due. Nothing then fires on
its own โ an external scheduler you own fetches the tick:
GET /api/internal/tick Authorization: Bearer $CRON_TRIGGER_SECRET
โ {"dispatched":["scheduleApply","scheduleProbe"],"alreadyClaimed":[],"drained":true}
The request claims and drains โ the same tick the in-process interval runs,
because nothing LISTENs any more, so if this request did not drain, nothing
would. It is bounded: past 25 seconds (under the 30-60s platform proxy floors)
the response says "drained": false while the drain carries on in-process, and
pinging again resumes a partially drained queue rather than duplicating it โ
which is what makes even a dumb every-five-minutes ping a working scheduler.
Choosing the interval on free serverless tiers. A five-minute ping works as a scheduler, but on a database that bills compute by the hour, five minutes is the wrong cadence for the work. Two thresholds decide it, and they pull in opposite directions:
- Neon suspends a compute only after 5 minutes with no queries. Every tick that reaches the database queries it, so a five-minute cadence leaves no five-minute gap and the compute never sleeps. That comes to about 730 hours a month, roughly 182 CU-hours at the smallest size, against the Free plan's 100. It runs out around day 16, and Neon then suspends the compute until the next billing period.
- Render's free tier spins a web service down after 15 minutes without inbound traffic, and waking takes about a minute, which is longer than most URL pingers wait. The ping has to stay well inside that.
So the ping and the work come apart. Keep the external ping at five minutes
and set FAST_PASS_INTERVAL_MINUTES=15. The fast passes (apply, the
read-pressure probe, and the stale-lock repair) then fall due once a quarter
hour, and a ping that finds nothing due answers from memory without a single
query:
โ {"dispatched":[],"alreadyClaimed":[],"drained":true,"idle":true}
Two pings in three look like that, and the database sleeps between the ones that do work. Expect roughly 60โ80 CU-hours a month, depending on how long each drain keeps it awake. That is an estimate, not a measurement, so check Neon's usage page after a few days. A ping still goes to the database in three cases:
- the last drain did not finish;
- a request queued a collect ("collect now", or the first collect after connecting a cluster);
- a failed job is waiting on its retry.
The cost is cadence, and it is worth stating plainly. An approved drop hides up to 15 minutes later, and a missing index shows up in read latency up to 15 minutes later. The hourly passes are unaffected. With the cadence raised, three things the process cannot see also wait for the next quarter hour rather than the next ping:
- a collect queued on another replica;
- a retry whose backoff outlived the ping after its failure;
- a watermark edited by hand.
A single replica, which is what a free host runs, sees none of them. At the
default of five, every ping asks the database, exactly as before. The value must
divide 60, because occurrences count from the top of each hour, and the boot
refuses anything else. The api needs nothing more for the database to sleep:
idle pool clients close after pg's default 10 seconds, long before the compute
does, and the dashboard's LISTEN is held only while somebody is watching
(#223), in a tab that is actually shown (#548).
Measured on the hosted deployment in October 2026, 15 was not enough (#614).
Neon showed 11.5 CU-hours after three and a half days, about 100 a month. Each
of the four fast-pass occurrences an hour keeps the compute up for five minutes
after it ends, and the hourly passes add their own length on top. With
FAST_PASS_INTERVAL_MINUTES=30, the fast passes coincide with the hourly ones
at :00 and :30, and the estimate falls to about 50 CU-hours. The cost is an
approved drop hiding up to half an hour later.
Egress and the host's outbound transfer are limits of their own (#614):
- Neon allows 5 GB a month of egress. The overview's two latency reads are folded in postgres. Before that, they shipped every reading of every collection over their window, nearly 5 MB a load on a cluster of a hundred MongoDB collections.
- Render's Hobby workspace allows 5 GB a month of outbound bandwidth. Without a payment method on file, Render suspends the free services for the rest of the month when it runs out. The dashboard compresses what it sends. The CDN in front compresses for the browser, but Render bills the bytes it sends to the CDN, and those used to go out raw.
After every deploy, the first collect of a large SQL Server can time out. Each Query Store plan's tables are read off its XML once and remembered in the process (#478), and a restarted process starts from empty. What a timed-out collect did read is kept, so the next one or two finish.
Free-tier storage is the next limit after compute: Neon's is 0.5 GB, and the sweep keeps each org's own plan window, 90 days on FREE and 183 on PRO. See Plans and policy โ History is kept for the plan's window, and no longer before relying on a free database for long.
GET, and only GET โ POST is not routed and answers 404. The endpoint
changes state, so by the letter of HTTP it wants POST, and that is what it
was; the method turned out to be the thing standing between an operator and a
working schedule. The audience for an external clock is the host that sleeps,
and what such a host comes with is a URL pinger โ an uptime monitor, a platform
cron field that takes a URL, a status-page checker โ most of which send GET
and nothing else. What a safe method actually promises an intermediary is that
it may repeat the request or reuse the answer, and both are already true here:
repeating is free by construction (below), and reuse is refused outright,
because every response this api sends carries Cache-Control: no-store. A
cached 200 would otherwise be this endpoint's worst failure โ a scheduler
being told the pipeline ran while nothing runs at all.
Safe to call as often as you like, and that is a property rather than a
promise โ it is also what makes serving this over GET unremarkable: passes are
claimed against their occurrence in worker_watermarks, so a hundred calls
inside one five-minute bucket enqueue at most one scheduleApply. Two crons
firing a minute apart compute the same occurrence and only one claim wins โ no
lock. A host that slept through eleven buckets owes
one run per pass, not eleven, so catching up never dials the fleet eleven
times over; retention stays on 03:00 and the weekly digest on Monday 09:00,
which an interval reading would have drifted. CRON_TRIGGER_SECRET is required
when RUN_CRONJOB=false โ the endpoint authorises the whole pipeline and there
is no user session behind it โ and the boot refuses without it, because a
deployment with no interval and no way in is a pipeline that is silently dead.
Hosts that sleep are a supported topology, with caveats named. On a host that sleeps when idle, or a database that suspends its compute, a resident schedule does not slow down, it stops โ which is why the external clock exists. Any scheduler that can fetch a URL with a bearer header drives the pipeline in request-sized bites: the work happens inside the request window, which is exactly what a platform that freezes the container after the response needs. The one loss on such a platform is the post-deadline tail of a long drain โ a frozen mid-flight job waits out graphile-worker's lock before retrying โ so keep the ping cadence at a few minutes and the tail stays short. Ticked every fifteen minutes, a five-minute pass is a fifteen-minute pass; that is a latency question, not a safety one โ the observe windows the drop pipeline runs on are measured in days.
On-demand actions ride the same tick: the dashboard's collect button and an approval enqueue, and the next drain โ at most 30 seconds away with the interval, or your ping cadence without it โ picks them up.
The api holds no database connection when nobody is watching. Its SSE
listener needs a dedicated session (LISTEN binds to one, so it cannot come
from a pool), and that session used to be opened at boot and held for the life
of the process โ which after burst mode was the only thing an idle api held,
and the one thing that pins a database that suspends when idle. It is lazy now:
the session opens on the first subscriber and closes thirty seconds after the
last one leaves. Measured on a built image, forty seconds idle, no dashboard
open: zero client backends. One while a stream is
live, which the integration suite asserts against pg_stat_activity rather than
against the service's own opinion of itself. Thirty seconds rather than zero
because the SSE route caps a stream at five minutes to re-check ownership, so an
open dashboard re-subscribes on a schedule and would otherwise churn the session
(D87).
One thing the tick design forced everywhere: the alert cooldown is a Postgres claim rather than a Map in process memory, so restarts and replicas cannot re-mail a cluster's owners on every tick of a bad day (D86).
One origin, guaranteed by the app. The browser calls the api itself, so the
session cookie only works if both answer on one origin. That is arranged twice
over, and the difference is a hop rather than whether it works โ a proxy in
front routes /api to the api (zero hops, what the ingress does), and with
nothing in front the dashboard server answers /api itself and forwards it
(src/lib/api-passthrough.ts, one transparent hop, no configuration). So
helm install without an ingress works, a port-forward works, npm run dev
works, and compose works; indexterity_web_requests_total{kind="api"} is
non-zero exactly when you have not set the proxy rule, which is how a missing
ingress rule stops being silent. The cost is that the api is publicly reachable,
so its rate limiting, origin checks and auth guard are now the only line of
defence rather than the second.
Those limits are worth reading precisely, because two limiters answer for them.
RATE_LIMIT_MAX (300/min) is counted in each api process's memory, so it is per
replica and a rolling deploy resets it. AUTH_RATE_LIMIT_MAX (20/min) is read
twice: the same per-replica way for /api/auth/*, and by better-auth for the
credential endpoints, which counts in Postgres โ so that half is one budget for the
whole deployment. Both count per client address, and TRUST_PROXY is what decides
what that address is โ for both, since the api resolves it once and hands
better-auth the answer rather than letting it read the header itself. Set it to the
CIDR ranges of whatever sits in front; on a managed host that does not publish
ranges, leave it unset once and read the request log's remoteAddress, which is
the address the platform connects from. Behind a CDN, name the header it sets
rather than trusting its published ranges, which its own customers can send from:
on a host whose proxy appends rather than replaces โ Render, behind Cloudflare โ
set CLIENT_IP_HEADER=cf-connecting-ip beside it,
with TRUST_PROXY=127.0.0.1 for the all-in-one image, whose dashboard forwards
/api over loopback. true is the one to avoid: it takes the first
entry in X-Forwarded-For, which a caller can write, so anywhere the platform
appends rather than replaces it lets each attempt choose the bucket it spends. The chart
requires it whenever its ingress is on and infers nothing โ it derived the
value from ingress.enabled once, which chose true with an ingress and false
without one, and a proxy routed to the Services from outside the chart is
invisible to that test.
Architecture ยง10.5
has the table.
A Helm chart is in deploy/helm/indexterity โ
api (pipeline included) + dashboard, a pre-upgrade migration hook, ingress, and a
helm test. Bring your own PostgreSQL. topology folds the two workloads into
one container (single-container) for an install where two Deployments is more
than it needs; the Services, the ingress and the app are identical either way, so
nothing in front of the chart has to know which one is installed.
Metrics
METRICS_ENABLED=true serves Prometheus metrics on port 9464 (METRICS_PORT)
from both workloads. Off unless asked for โ an exporter costs memory in
every process, and an install with nothing scraping it pays that for nobody. The
chart can turn it on and install a ServiceMonitor per workload; compose has it on
and publishes 9464 (api) and 9466 (web).
Each answers for what only it can see, so scrape both. The seven things a service that drops other people's indexes has to be able to state:
| question | metric |
|---|---|
| how many clusters can we not reach right now | indexterity_clusters_unreachable |
| how long is the job queue | indexterity_jobs{state="queued"}, indexterity_jobs_oldest_queued_age_seconds |
| how many drops are mid-observe | indexterity_recommendations{state="HIDDEN"} |
| how often does the regression gate fire | indexterity_regression_gate_decisions_total{verdict="regressed"} |
| what is the dead-letter rate | rate(indexterity_job_runs_total{outcome="dead_letter"}[1h]), backlog in indexterity_jobs{state="dead_letter"} |
| is run-length storage still saving anything | indexterity_evidence_writes_total{outcome="extended"} against {outcome="inserted"}, by engine and table |
| does anything we say ever leave the building | indexterity_mail_sends_total{outcome="refused"} and {outcome="disabled"} against the total, by purpose |
An unreachable cluster is a handled condition โ the tick is skipped and
retried, nothing fails โ so the queue counters cannot see it and
indexterity_cluster_task_runs_total{outcome=...} is where the six ways a tick
can end are told apart.
The last row is a saving rather than a failure, and it is here because it
stopped working once without anything noticing. index_snapshots and
latency_samples write one row per distinct counter state rather than one per
collect; a reading that never repeats byte for byte folds nothing, and the tables
go back to a row per collect per index with no error and no log line. A ratio of
extended to inserted near zero on an engine is that condition.
The mail row is the other one that is here because its failure looks like
nothing. A deployment with no SMTP, a transport refusing every send, and a week
where no cluster went wrong produce identical silence โ no exception, no failed
job, no empty panel โ and outcome="disabled" is the branch that was invisible:
sending is a no-op, the alert cooldown correctly treats it as settled because no
retry could do better, and the mail is simply gone. Set
metrics.prometheusRule.mailRequired on an install that means to send.
What none of it can see is a message the transport ACCEPTED and the recipient's
server then rejected. Those count as sent, because acceptance is the strongest
claim the sending process can make โ the rejection happens somewhere this
deployment never hears about. The only thing that reports them is DMARC, so if
you send from a subdomain of a strict-DMARC parent, publish a _dmarc record
with a rua= address at the sending domain before the first send. An inherited
sp=reject rejects silently: no bounce, no log line, and every alert, invite and
password reset gone with nothing anywhere to say so.
The endpoint has no auth, which is why it is a second port instead of a route
on the app. metrics.prometheusRule.enabled=true installs 20 alerts with the
metrics, because a scrape endpoint nobody wrote queries against is a scrape
endpoint nobody reads. deploy/helm/indexterity/README.md has the thresholds and
the label-selector gotcha; what the dashboard server reports that the api cannot,
and the two alert traps worth knowing, are in
Architecture ยง15.
Errors
The metrics above say how often something failed. SENTRY_DSN says what.
Every process reads that one variable; leave it empty โ the default here, in
compose and in the chart โ and neither app initialises the SDK. The dashboard
does not even load it: importing @sentry/tanstackstart-react costs 17.3 MB of
heap, measured, and a dynamic import() there is real ESM, so a default install
skips it entirely. The api loads @sentry/nestjs (12.5 MB) either way โ a
deliberate tradeoff, not an oversight, kept for full static typing over that
saving. See D75 for why the two apps land differently.
The DSN is yours. A self-hosted install reports to your own Sentry organisation or your own self-hosted Sentry, never to us.
Two projects. The api โ pipeline included, one process since #232 โ and the
dashboard, which is a separate app with a separate release. In compose that is
SENTRY_DSN_API and SENTRY_DSN_WEB; in the chart, errorReporting.dsn and
errorReporting.webDsn.
| workload | what is reported |
|---|---|
| api | unhandled 500s from AppExceptionFilter, tagged with the request id already in the log line and the response body โ and a job that burned its last retry (the dead-letter transition), tagged with task, attempt and cluster |
| web | server-side render failures, and anything the passthrough throws |
Plus the unhandled-rejection and uncaught-exception sinks the SDK installs in every process, which is the half that had no home before.
Reported once each, and only for things that are actually faults. A retry is not an event (the counters above already say how often jobs retry), and neither is a 429, a 413, a 404, or a cluster whose owner's firewall is doing its job โ an unreachable cluster is a handled condition, so paging about it would be paging about somebody else's network.
Nothing leaves with a connection string in it. packages/errors strips
MongoDB and Postgres URIs whole โ hosts included, because a replica set's member
list is the customer's infrastructure and +srv resolves to it โ plus the
credentials of any other URI, any field named like a secret (a superset of the
Fastify logger's redact list), the same secrets encoded inside a JSON string,
where key matching cannot see them, and the values of a failed query โ the
params: line better-auth logs on a database error, which reaches Sentry as a
breadcrumb. It walks the entire event rather than a list
of known fields, because the way a field list fails is silently, on the day the
SDK adds a field. Unit-tested against a seeded event carrying the string in every
place one could hide.
The request body is dropped entirely, not scrubbed: POST /clusters carries
a customer's connection string as a field. sendDefaultPii: false does not
cover this โ in SDK 10.69 bodies, stack locals and database payloads are governed
by dataCollection, and its defaults collect all three. All are off, and the body
is removed in beforeSend as well, because setting httpBodies: [] did not stop
it arriving. That was measured against the running stack, not read off the docs.
Tracing, profiling, session replay and Sentry's own metrics are all off: the