Observability¶
The Prometheus metric catalog (spec_v2 §6.2) and its recording helpers.
The probe points live in the pipeline flows, the reranked retriever, and
the API health probes; the catalog is exposed at GET /metrics and
charted by the provisioned Grafana dashboards under observability/.
Two kinds of instrument, and the difference decides which panel can answer which question:
| Source | Survives an API restart? | Sees CLI ingests? | |
|---|---|---|---|
varagity_corpus_* gauges |
pgvector, at scrape time | yes | yes |
varagity_graph_* gauges |
the graph workdir's files, at scrape time | yes | n/a — graph builds are API-only |
| counters / histograms | this process | no | no (ADR-007) |
Ingestion is rare and bursty, so increase()/rate() over an ingest
counter reads 0 over any window — the counter's series is born at its
full value after a restart, so Prometheus never observes the rise. Corpus
size is therefore a gauge question, not a counter question (spec_v3 §6.1;
ADR-013 is the full record);
tests/unit/test_dashboards.py fails the build if a panel regresses to the
counter form.
Both gauge families emit no samples at all rather than zeros when
their store has never answered — an unbuilt graph is not a graph with zero
entities — and serve the last good snapshot through an outage, because a
scrape that raises takes /metrics down including the metrics that are
fine.
varagity.observability
¶
In-app observability: the Prometheus metric catalog (spec_v2 §6).
:mod:varagity.observability.metrics holds the spec_v2 §6.2 catalog plus
the recording helpers the pipeline probe points call — instruments that
record what this process did.
:mod:varagity.observability.corpus adds the complement: gauges read from
pgvector at scrape time, answering what is in the store. Process history
resets when the API restarts and misses CLI ingests entirely, which is what
left the Ingestion dashboard reading zero through v2 (spec_v3 §6.1).
:mod:varagity.observability.graph applies that same shape to the graph
corpus, reading the engine's working directory (never the engine) at scrape
time — a backfill that ran for hours yesterday is a state question too.
The API's GET /metrics route exposes the same process-wide registry.
varagity.observability.metrics
¶
Prometheus instrumentation: the spec_v2 §6.2 metric catalog + recording helpers.
The catalog registers in prometheus_client's default process-wide
registry and is exposed by the API's GET /metrics (spec_v2 §6.1 source
1 — direct in-app instrumentation, the primary metrics source; plan
decision #10). Probe points call the observe_*/count_*/set_*
helpers below instead of touching the metric objects, so the label
vocabulary stays in one place:
- the Prefect flows (:mod:
varagity.pipeline.query_flow, :mod:varagity.pipeline.ingest_flow) — per-stage query latency, retrieval scores + rerank deltas, query/ingest counters, LLM tokens; - the reranked retriever (:mod:
varagity.retrieval.reranked) — thererankstage's latency; - the API health probes (:mod:
varagity.api.deps) — per-dependency reachability.
Metrics accumulate per process: the API (a single uvicorn worker — plan decision #11, no multiprocess registry) is what Prometheus scrapes. A CLI run records into its own short-lived process instead — harmless, and it keeps the instrumented code path identical for both front-ends.
Recording is unconditional; settings.METRICS_ENABLED gates only the
/metrics endpoint (the collectors are cheap in-memory counters).
catalog()
¶
Map every metric this app exposes to its label names.
The dashboard guard test (spec_v3 §6.4) checks each panel expression
against this, so a renamed metric or an undeclared sum by (…) label
fails the unit suite instead of silently emptying a panel.
Names are prometheus_client's internal base names, which are not
always what a dashboard queries: the client strips _total from a
Counter (varagity_ingest_docs_total is catalogued as
varagity_ingest_docs) and appends _bucket/_sum/_count
to a Histogram at exposition time. Reconciling those suffixes is the
caller's job.
Returns:
| Type | Description |
|---|---|
dict[str, tuple[str, ...]]
|
Metric name → declared label names, covering the module-level |
dict[str, tuple[str, ...]]
|
catalog, the store-derived corpus gauges |
dict[str, tuple[str, ...]]
|
(:mod: |
dict[str, tuple[str, ...]]
|
graph gauges (:mod: |
Source code in varagity/observability/metrics.py
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | |
count_ingested_chunks(chunking_strategy, n)
¶
Count chunks stored by ingestion.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunking_strategy
|
str
|
Registry name of the strategy that produced them. |
required |
n
|
int
|
How many chunks the document stored. |
required |
Source code in varagity/observability/metrics.py
208 209 210 211 212 213 214 215 | |
count_ingested_document(file_type, extraction)
¶
Count one document stored by ingestion.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
file_type
|
str
|
The document's file type ( |
required |
extraction
|
str
|
Extraction provenance ( |
required |
Source code in varagity/observability/metrics.py
197 198 199 200 201 202 203 204 205 | |
count_llm_tokens(prompt_tokens, completion_tokens)
¶
Count server-reported token usage for one streamed answer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prompt_tokens
|
int | None
|
Prompt-side tokens, or |
required |
completion_tokens
|
int | None
|
Completion-side tokens, same convention. |
required |
Source code in varagity/observability/metrics.py
227 228 229 230 231 232 233 234 235 236 237 238 | |
count_query(method, outcome)
¶
Count one completed query flow.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
The retrieval method's registry name. |
required |
outcome
|
str
|
|
required |
Source code in varagity/observability/metrics.py
186 187 188 189 190 191 192 193 194 | |
observe_contextualize(seconds)
¶
Record one document's situating-blurb generation latency.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seconds
|
float
|
Wall-clock duration of the document's contextualize stage. |
required |
Source code in varagity/observability/metrics.py
218 219 220 221 222 223 224 | |
observe_query_stage(stage, method, seconds)
¶
Record one query stage's wall-clock latency.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stage
|
str
|
Pipeline stage name ( |
required |
method
|
str
|
The retrieval method's registry name (low-cardinality
label; injected non-registry implementations record as
|
required |
seconds
|
float
|
The stage's duration. |
required |
Source code in varagity/observability/metrics.py
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | |
observe_retrieval(method, chunks)
¶
Record the retrieved chunks' final scores and any rerank movement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
The retrieval method's registry name. |
required |
chunks
|
Sequence[RetrievedChunk]
|
The query's final retrieved chunks, best first. Each score
lands in the |
required |
Source code in varagity/observability/metrics.py
170 171 172 173 174 175 176 177 178 179 180 181 182 183 | |
set_dependency_up(service, up)
¶
Record one backing service's probe outcome.
Refreshed by every health probe — in compose, the api container's
own healthcheck hits GET /api/health every 15 s, so the gauge stays
current without a dedicated poller.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
service
|
str
|
The service name ( |
required |
up
|
bool
|
Whether the probe succeeded. |
required |
Source code in varagity/observability/metrics.py
241 242 243 244 245 246 247 248 249 250 251 252 253 | |
set_last_ingest_run(finished_at, duration_seconds, documents)
¶
Record the last completed ingest run's headline facts.
Set by the API's ingest runner on completion (spec_v3 §6.1a). CLI ingests can't feed these — they record into their own never-scraped registry (ADR-007) — which is why the corpus gauges, not these, are what the dashboard's size panels read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
finished_at
|
float
|
Unix timestamp of the run's completion. |
required |
duration_seconds
|
float
|
The run's wall-clock duration. |
required |
documents
|
int
|
How many documents the run processed. |
required |
Source code in varagity/observability/metrics.py
256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | |
varagity.observability.corpus
¶
Store-derived corpus gauges: the spec_v3 §6.1a scrape-time collector.
The ingest counters in :mod:varagity.observability.metrics answer "what
did this process do" — and they are the wrong instrument for "how big is
my corpus". Ingestion is rare and bursty, so a counter sits flat for hours
and increase() over it is 0; worse, after an API restart the series
is absent and then reappears at its full value, so Prometheus never sees
the 0 → 1 rise and increase() is 0 even over $__range. That
is what made every Ingestion panel read zero while the metrics themselves
were correct (spec_v3 §2.3, §6.1).
Gauges read from pgvector at scrape time have neither problem: they reflect store state, survive an API restart, and — unlike the counters (ADR-007) — they also show CLI ingests, which record into a never-scraped registry.
Failure posture: a store outage must degrade /metrics, never 500 it.
The last good snapshot is re-served (going stale) and a fresh process with
no snapshot yet emits the gauges with no samples at all.
:data:varagity.observability.metrics.DEPENDENCY_UP is what reports store
health; these gauges deliberately do not. A backup restore is the same
posture: its TRUNCATE holds both tables until it commits (ADR-019), so
the gauge connection carries a lock timeout — a scrape during the load gives
up on the lock within a second and serves the last snapshot rather than
hanging for the whole restore.
CorpusCollector
¶
Bases: Collector
Expose the corpus gauges, querying pgvector at scrape time.
Registered in the default registry by the API's app factory when
METRICS_ENABLED (:func:register_corpus_collector). A short TTL
(:data:CACHE_TTL_SECONDS) bounds the query rate; a store outage
serves the last good snapshot rather than raising.
Source code in varagity/observability/corpus.py
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | |
__init__(store_factory=gauge_store, ttl_seconds=CACHE_TTL_SECONDS, clock=time.monotonic)
¶
Configure the collector's store access and caching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store_factory
|
Callable[[], ContextualVectorDB]
|
Builds a connected store per refresh (override
seam for tests); :func: |
gauge_store
|
ttl_seconds
|
float
|
How long a snapshot is served before re-querying. |
CACHE_TTL_SECONDS
|
clock
|
Callable[[], float]
|
Monotonic time source (override seam for tests). |
monotonic
|
Source code in varagity/observability/corpus.py
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | |
collect()
¶
Emit the corpus gauges for one scrape.
Yields:
| Type | Description |
|---|---|
GaugeMetricFamily
|
The gauge families, populated from a fresh snapshot, the |
GaugeMetricFamily
|
cached one, or — if the store has never been reachable in this |
GaugeMetricFamily
|
process — with no samples. |
Source code in varagity/observability/corpus.py
148 149 150 151 152 153 154 155 156 157 158 159 160 | |
describe()
¶
Declare the gauge families without touching the store.
Registration calls this to detect duplicate names; without it
prometheus_client would call :meth:collect at registration
time, turning app startup into a database query.
Returns:
| Type | Description |
|---|---|
Iterable[GaugeMetricFamily]
|
The empty gauge families this collector emits. |
Source code in varagity/observability/corpus.py
136 137 138 139 140 141 142 143 144 145 146 | |
CorpusSnapshot
dataclass
¶
One scrape's worth of corpus counts.
Attributes:
| Name | Type | Description |
|---|---|---|
documents |
int
|
Total ingested documents. |
chunks |
int
|
Total stored chunks. |
documents_by_type |
dict[str, int]
|
|
chunks_by_strategy |
dict[str, int]
|
|
Source code in varagity/observability/corpus.py
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | |
gauge_store()
¶
Connect the store the gauges query, with a short lock timeout.
A restore holds documents and chunks exclusively until it
commits; with the timeout, a count query against them fails fast
(psycopg.errors.LockNotAvailable) and the collector serves its last
snapshot instead of queueing behind the restore.
Returns:
| Type | Description |
|---|---|
ContextualVectorDB
|
A connected store. |
Raises:
| Type | Description |
|---|---|
OperationalError
|
If the database is unreachable. |
Source code in varagity/observability/corpus.py
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
register_corpus_collector(registry=REGISTRY, collector=None)
¶
Register the corpus collector, replacing any previous registration.
Idempotent on purpose: create_app() runs once per process in
production but many times across a test session, and re-registering the
same gauge names in the process-wide default registry would raise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
CollectorRegistry
|
The registry to register into (defaults to the
process-wide one that |
REGISTRY
|
collector
|
CorpusCollector | None
|
The collector to register; a default-configured one is built when omitted. |
None
|
Returns:
| Type | Description |
|---|---|
CorpusCollector
|
The registered collector. |
Source code in varagity/observability/corpus.py
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | |
varagity.observability.graph
¶
Workdir-derived graph gauges: the ADR-013 shape, applied to the graph.
The graph corpus has exactly the problem the chunk-RAG corpus had before
ADR-013: "how big is my graph" is a state question, and an in-process
counter cannot answer it. A backfill runs for hours and then stops; the
counter that recorded it is born at its full value after the next API
restart, so increase() over it is 0 — over any window. Gauges read
at scrape time have neither problem.
Where they read from is the difference. The corpus gauges query pgvector; these read the files in the engine's working directory, and never open the engine (stage-2 decision #18):
varagity_graph_documents{status}counts the engine's own doc-status records — the same pending/processing/processed/failed vocabulary the Graph RAG tab shows, so a stalled or failed backfill is visible on the dashboard rather than only in the tab.varagity_graph_entities/_relations/_messagescome from the summary sidecar every build and delete refreshes (:mod:varagity.graph.manifest), which exists precisely so a status poll or a scrape never walks a multi-megabyte graphml.
Opening a session here would be worse than slow: the engine is single-writer per working directory and this process is that writer, so a scrape must not initialize storages a build is holding — and on an unbuilt graph it would create the workdir, turning a Prometheus scrape into "the graph now exists".
Failure posture matches the corpus collector's. An unreadable file serves
the last good snapshot (going stale) rather than raising, because a scrape
that 500s takes /metrics down including the metrics that are fine. An
unbuilt graph is not an error and not a zero: the families are emitted
with no samples at all, the same honesty
:class:~varagity.api.schemas.GraphStatusOut keeps when it reports None
rather than 0 for a graph nothing has been extracted into yet.
GraphCollector
¶
Bases: Collector
Expose the graph gauges, reading the workdir at scrape time.
Registered in the default registry by the API's app factory when
METRICS_ENABLED (:func:register_graph_collector). A short TTL
(:data:CACHE_TTL_SECONDS) bounds the read rate; an unreadable workdir
serves the last good snapshot rather than raising.
Source code in varagity/observability/graph.py
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | |
__init__(workdir_factory=graph_workdir, ttl_seconds=CACHE_TTL_SECONDS, clock=time.monotonic)
¶
Configure the collector's workdir access and caching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir_factory
|
Callable[[], Path]
|
Resolves the graph's working directory per refresh (override seam for tests). Deliberately the shared settings derivation rather than the graph service's own property: a scrape must not depend on the engine registry being warm, nor construct the service singleton. Called per refresh, not once, so a settings change lands without a restart. |
graph_workdir
|
ttl_seconds
|
float
|
How long a snapshot is served before re-reading. |
CACHE_TTL_SECONDS
|
clock
|
Callable[[], float]
|
Monotonic time source (override seam for tests). |
monotonic
|
Source code in varagity/observability/graph.py
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | |
collect()
¶
Emit the graph gauges for one scrape.
Yields:
| Type | Description |
|---|---|
GaugeMetricFamily
|
The gauge families, populated from a fresh snapshot, the cached |
GaugeMetricFamily
|
one, or — for an unbuilt graph or a workdir that has never been |
GaugeMetricFamily
|
readable — with no samples. |
Source code in varagity/observability/graph.py
180 181 182 183 184 185 186 187 188 | |
describe()
¶
Declare the gauge families without touching the workdir.
Registration calls this to detect duplicate names; without it
prometheus_client would call :meth:collect at registration
time, turning app startup into a disk read.
Returns:
| Type | Description |
|---|---|
Iterable[GaugeMetricFamily]
|
The empty gauge families this collector emits. |
Source code in varagity/observability/graph.py
168 169 170 171 172 173 174 175 176 177 178 | |
GraphSnapshot
dataclass
¶
One scrape's worth of graph size, as the workdir reports it.
Every field is optional-shaped because "nothing has been built here"
and "the graph would not say" are both real states, and a 0 would
read like an empty graph on a dashboard.
Attributes:
| Name | Type | Description |
|---|---|---|
documents_by_status |
dict[str, int]
|
Engine doc-status name → document count. |
entities |
int | None
|
Nodes in the graph, or |
relations |
int | None
|
Edges in the graph, or |
messages |
int | None
|
Distinct messages the manifest accounts for, or |
Source code in varagity/observability/graph.py
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
read_document_statuses(workdir)
¶
Count the engine's documents by processing status, from disk.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
workdir
|
Path
|
The engine's working directory. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, int]
|
Status name → document count. An absent file is an empty mapping: |
dict[str, int]
|
a workdir that has never had a document enqueued genuinely has no |
dict[str, int]
|
statuses, which is not a failure. |
Raises:
| Type | Description |
|---|---|
OSError
|
If the file exists but cannot be read. |
ValueError
|
If it is not readable JSON — the caller degrades to the last good snapshot rather than reporting a suddenly empty graph. |
Source code in varagity/observability/graph.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | |
register_graph_collector(registry=REGISTRY, collector=None)
¶
Register the graph collector, replacing any previous registration.
Idempotent for the corpus collector's reason: create_app() runs once
per process in production but many times across a test session, and
re-registering the same gauge names in the process-wide default registry
would raise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry
|
CollectorRegistry
|
The registry to register into (defaults to the
process-wide one that |
REGISTRY
|
collector
|
GraphCollector | None
|
The collector to register; a default-configured one is built when omitted. |
None
|
Returns:
| Type | Description |
|---|---|
GraphCollector
|
The registered collector. |
Source code in varagity/observability/graph.py
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |