Observability
Two probes and an OpenTelemetry layer, in the same shape as ra-mcp.
Liveness and readiness are different questions
The server boots without a table on purpose — the image ships empty, the table is mounted or ingested separately, and a missing mount should be a readable message rather than a crash loop. That design has a consequence: "the process is up" and "the process can answer a search" are genuinely different states, and a single always-200 probe reported only the first.
| route | question | when the table is missing |
|---|---|---|
/health |
Liveness — is it serving HTTP? Restart it if not. | 200 {"status": "ok"} |
/ready |
Readiness — can it actually search? Hold traffic back if not. | 503 {"status": "not ready", "reason": "…"} |
/health deliberately does not touch LanceDB: a liveness probe that failed on
missing data would restart a process behaving exactly as designed. /ready goes
through the same get_search() the tools use, so readiness and the tools agree by
construction — if /ready says ready, a tool call will not come back with the
missing-table error. It never raises: a probe that 500s tells an orchestrator less
than one that answers "not ready" and why.
In Kubernetes, wire livenessProbe to /health and readinessProbe to /ready.
Every call is one log line
Telemetry is optional (below); the log is not. The Hugging Face Space runs without a
collector, so its log is the only record of what the server is asked — and until v0.3.1
it recorded nothing per call beyond the MCP library's Processing request of type
CallToolRequest. Now every tool call writes one INFO line from
ra_mcp_kansallisarkisto_mcp.errors, the same place every failure is turned into text:
df_search keyword='konung' issuingplace='Åbo' -> 49 hits in 118 ms
tuomiokirjat_search keyword='hustru' series='Turun raastuvanoikeuden' year_min=1650 year_max=1660 -> 604 hits in 251 ms
voudintilit_search keyword='smör' research_context='butter tithes' -> 10000+ hits in 340 ms
df_get_charter df_number=2457 -> found in 6 ms
tuomiokirjat_get_page page_id='nonsense' -> not found in 4 ms
df_search keyword='"de ecclesia"' fuzzy=1 -> validation: fuzzy=1 cannot be combined with a quoted phrase in 0 ms
df_search keyword='konung' -> missing table in 1 ms
df_search keyword='konung' -> RuntimeError in 12 ms
Only the arguments the caller set appear — a line of language=None issuingplace=None on
every call would be noise — and paging options only when changed from their defaults. The
outcome is the hit count (a floor, 10000+, when the total is capped), found / not
found for a lookup, or the kind of failure; an internal error's traceback is logged
separately, once, by the same handler. research_context is the one argument that exists
for this line alone: a sentence on what the user is researching, which the tools accept on
every search so that an operator can see what the corpora are asked for.
At boot the server names its version and each table with its row count —
LanceDB at /data-local — tables: df (6,876 rows), tuomiokirjat (7,742,958 rows),
voudintilit (98,945 rows) — which is what tells a fixture from the corpus in a deploy log.
FastMCP's own logger is handed to the same stderr handler, so its warnings (a bad tool
argument, say) come out as one line in the same format rather than wrapped by a rich console
at 80 columns.
Telemetry is off by default
Nothing is exported unless KA_MCP_OTEL_ENABLED is set. With it unset the
instrumentation resolves to no-op tracers and meters, which is what a stdio client
on a laptop should get — no collector to reach, and no cost for having the code
there.
| variable | default | meaning |
|---|---|---|
KA_MCP_OTEL_ENABLED |
false |
Master switch. |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 |
Collector endpoint. |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc |
grpc or http/protobuf. |
OTEL_SERVICE_NAME |
kansallisarkisto-mcp |
Service name on the resource. |
KA_MCP_OTEL_LOG_BRIDGE |
true |
Bridge Python logging to OTel logs. |
Two layers, one trace
FastMCP instruments the MCP boundary automatically. This project instruments the
layers beneath it by hand. Both share the global TracerProvider, so they nest:
tools/call df_search ← FastMCP, automatic
└── DfSearch.search ← manual: what was asked, in the caller's terms
└── search df ← manual: what was run against LanceDB
The split is deliberate. The operations span knows the question — which filters the caller set, in their own vocabulary — while the query span knows the execution: the SQL predicate, the row counts, the latency. Rolling them into one span would lose whichever half you did not name it after.
Never add a manual span inside an @mcp.tool() handler. FastMCP already
covers that boundary, and a second span there only nests a duplicate.
Where the SDK lives
kansallisarkisto-lib depends on opentelemetry-api only — never the SDK.
The SDK and the OTLP exporters are dependencies of kansallisarkisto-mcp, and
init_telemetry() in its telemetry module is the single place they are touched.
That is what makes the lib usable on its own without dragging in an exporter, and
what makes "telemetry off" genuinely free.
What is recorded
| span | attributes |
|---|---|
DfSearch.search |
df.keyword, df.limit, df.offset, df.match_all, df.fuzzy, plus df.language / df.issuingplace / df.country / df.year_min / df.year_max only when set |
search df |
db.system, db.collection.name, db.query.text, db.query.filter, db.response.total_hits, db.response.returned_rows, db.response.total_is_capped |
DfSearch.get_charter |
df.number, df.found |
Metrics, on the LanceDB layer where every search passes:
kansallisarkisto.lancedb.queries, .errors, .query.duration, and .results.
.results is a behavioural signal rather than a health one — its zero bucket is
searches that matched nothing, which is what people looked for that this corpus
cannot answer. The query terms stay on the span, not on the metric, where they
would explode cardinality.
Errors have to be marked explicitly
Every tool here returns text rather than raising — an exception reaches the
model as a protocol error it cannot act on. But FastMCP's tools/call span
reports OK whenever a handler returns normally, so without help, a server whose
every call answered "the df table is not available" would show a tool failure rate
of exactly zero. mark_span_error() is called on each error-string path to flag
the span, with error.type grouping the class (validation, missing_table, or
the exception name).
Shutdown is bounded on purpose
shutdown_telemetry() is registered with atexit, because without a final flush
the last batch of spans is lost every time the process exits — and for stdio that
is after every session.
It runs the flush on a daemon thread joined with a timeout, and that is not
belt-and-braces. Measured with the SDK enabled and no collector listening, an
unbounded shutdown never returned: the OTLP gRPC exporter retries an unreachable
endpoint with exponential backoff, shutdown() waits for that retry loop, and
force_flush(timeout_millis=…) does not bound it. Since this runs at exit, that
hung the process on every exit precisely when telemetry was misconfigured. A dead
collector now costs SHUTDOWN_TIMEOUT_SECONDS and a warning, never a hang.
Verifying the cascade
The trace tree is the invariant: if the two layers do not share a provider they
produce two disconnected traces and no backend can show LanceDB latency under a
tool call. packages/kansallisarkisto-mcp/tests/test_telemetry.py asserts it
against a real in-memory span exporter — that the query span's parent is the
operations span, and that both sit in the same trace as FastMCP's tools/call.