Monitoring
fenec-server counts every statement it answers and serves the counts to Prometheus, with a Grafana dashboard ready to load.
Turn it on
Give --metrics an address and a token, and point Prometheus at
/_metrics there. Counts cover every request, reads and
writes apart, with how long each took.
# /_metrics on a listener of its own, away from the data's
fenec-server --file data.fenec --http 127.0.0.1:8080 \
--metrics 0.0.0.0:9464 --admin-token "$SCRAPE" --slow-ms 100
curl -H "Authorization: Bearer $SCRAPE" http://127.0.0.1:9464/_metrics
# fenec_statements_total{kind="read"} 1834
# fenec_statement_duration_seconds_bucket{kind="read",le="0.001"} 1790
# fenec_documents{collection="people"} 90000
# ...- With
--http, the HTTP listener serves/_metricstoo. - A scrape needs a token: the server's (
--http-token) or the admin one (--admin-token). - Datadog, New Relic, Elastic, Grafana Cloud read the same metrics and logs: Datadog and other monitoring systems.
Details
With --http the HTTP listener serves /_metrics
as well, and a --dir node serves it there. The path carries an
underscore, as /_admin and /_replication do: a
collection may well be called metrics, and
GET /metrics is how its rows are read. Prometheus takes the path
as metrics_path.
A scrape needs the server's token (--http-token) or the admin
token (--admin-token); with neither and no JSON Web Tokens the
server is open, and so are its metrics. A JWT is not taken: a user held to a
policy must not learn the other collections' names from a scrape. A
--metrics address outside loopback wants one of the two tokens,
as the HTTP listener does, or --insecure.
What is counted
Each statement is counted as a read or a write, with its latency as the client saw it. Collections, memory, replication and tenants are reported as gauges.
| Metric | Type | What it says |
|---|---|---|
fenec_statements_total{kind} | counter | statements answered; kind is read or write |
fenec_statement_errors_total{kind} | counter | statements answered with an error, the client's (a typo, a 404) or the server's |
fenec_statement_duration_seconds{kind} | histogram | arrival to answer, in buckets from 100 µs to 10 s |
fenec_slow_statements_total | counter | statements over --slow-ms, each also in the log |
fenec_refused_total | counter | requests refused for their token (401), each also in the audit log |
fenec_connections | gauge | connections open now; a scrape is not one |
fenec_documents{collection} | gauge | documents in a collection |
fenec_data_bytes{collection}, fenec_dead_bytes{collection} | gauge | live records, and the overwritten and deleted ones compact gives back |
fenec_file_bytes, fenec_reclaimable_bytes | gauge | the file as written, and what a compact would give back of it now: the dead records, a record's head a write, the graph records a server appended in the tail; --auto-compact runs one once it passes its share (Compaction) |
fenec_auto_compactions_total | counter | compacts the server ran on its own, every file it serves together |
fenec_memory_bytes | gauge | what the process holds of the database and --max-memory counts: the indexes, and the documents too where the file is not mapped (--no-mmap) |
fenec_change_sequence | gauge | the last write's sequence number; its rate is the writes per second |
fenec_storage_failed | gauge | 1 once the disk refused a write: writes stop until the file is reopened, reads go on |
fenec_vectors_unlinked | gauge | vectors the open left out of the graph after a crash: near measures each one until a thread beside the queries has linked them, and the gauge falls to 0; on a --dir node, the open tenants' together |
fenec_replicas | gauge | on a primary, the replicas streaming from it |
fenec_replica_connected, fenec_replica_behind | gauge | on a replica: whether it hears its primary, and the writes on the primary's disk it has not applied |
fenec_replica_last_contact_seconds | gauge | since the primary was last heard from, a keep-alive included |
fenec_replica_images_total, fenec_replica_reconnects_total | counter | whole images taken, and connections made again |
fenec_tenants, fenec_tenants_open, fenec_tenants_disk_bytes | gauge | on a --dir node: tenants on disk, open, and their files together |
fenec_build_info{version}, fenec_start_time_seconds | gauge | the version running, and when the process started |
Details
A statement is timed from its arrival to its answer: the wait for the lock
is in it, and under --sync always the wait for the disk, since
that is the latency a client sees. A statement is a request, and
/batch is one however
many statements it carries. A statement that took the write lock is a write,
so a get sent through POST /query is a read.
A tenant node counts its tenants and not what they hold: a tenant's
collection names are not the node's to publish, and a label per tenant would
be as many series as there are customers. The statement counters are one set
per process, and start again at zero with it, which rate() takes
in its stride.
Grafana
The repository's monitoring/ folder runs fenec-server with
Prometheus and Grafana and the dashboards already loaded: the server's
metrics, and the database's own rows through fenecdb's data source plugin
and through the Infinity plugin.
FENEC_HTTP_TOKEN=secret FENEC_METRICS_TOKEN=scrape \
FENEC_JWT_SECRET=$(openssl rand -hex 32) \
docker compose -f monitoring/docker-compose.yml up -d --build
# metrics: http://127.0.0.1:3000/d/fenecdb data: http://127.0.0.1:3000/d/fenecdb-data-plugin
# Prometheus: http://127.0.0.1:9090- The metrics go fenec-server → Prometheus → Grafana, behind the
admin token: the dashboard is
grafana/fenecdb.json. - The data goes Grafana → the
fenecdb-datasourceplugin →POST /query, each panel a FenecQL statement with the time range and variables bound as its parameters, under a token that reads two collections and writes nothing, with an alert rule over the same rows: Grafana.
Details
monitoring/ in the repository holds a Grafana dashboard
(grafana/fenecdb.json), a Prometheus configuration and a compose
file that runs fenec-server with both, the dashboard already loaded. The
same compose seeds a site's events and a week of trades and loads the data
dashboards (grafana/fenecdb-data-plugin.json and
grafana/fenecdb-data.json) beside it.
It opens on the figures that decide whether anything needs doing — the
version and uptime, whether the disk has refused a write, statements and
errors per second, the memory footprint — then statements by
kind with their p50 and p99, errors, and slow statements a minute; documents,
writes a second and each collection's dead share, the one that says when to
compact; replication; tenants; and a router's requests, its
latency beside its nodes', the nodes it could not reach, the tenants on each
node and its moves. The instance variable picks one server or all of them.
Imported on its own, the dashboard asks for a Prometheus data source.
# the p99 of reads, over five minutes
histogram_quantile(0.99, sum by (le) (rate(fenec_statement_duration_seconds_bucket{kind="read"}[5m])))
# worth an alert
fenec_storage_failed == 1
fenec_replica_behind > 10000
fenec_dead_bytes / (fenec_data_bytes + fenec_dead_bytes) > 0.5Slow statements
--slow-ms 100 logs every statement that took 100 ms or
longer, with what it ran, as a JSON line on stderr.
{"at":"2026-10-05T09:30:12.310Z","event":"slow","level":"warn","proto":"http","peer":"10.0.0.7:53112","request_id":"9f2c41d07ab35e18","duration_ms":312.408,"kind":"read","failed":false,"statement":"POST /query {\"query\":\"get people select name order name collate tr\"}"}Details
The log line uses the same timing the histogram has. It is the audit
log's shape — at, event, level, the
peer and the request id — with the duration in
milliseconds, whether the statement wrote (kind), whether it
failed, the tenant on a tenant node, and the statement, so one parser reads
both logs (Datadog and other monitoring
systems). --slow-ms 0 logs every statement, as PostgreSQL's
log_min_duration_statement = 0 does.
A parameter's value is not logged, only how many there were: a value bound
as $1 stays out of the log, a literal written into the text does
not. Over HTTP the line is the method and the target, and for
/query and /batch the body that holds the
statements. A line is cut at 1 000 bytes, so a bulk put of a
megabyte shows what it was without being all of it.
What each statement cost
Statements are also grouped by shape — the same text with other values is
one line — with call counts, times, rows and failures, at
/_stats/statements.
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/_stats/statements
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/_stats/statementsDetails
Every statement is also counted by its shape — its text with each literal
and parameter written $1, $2… and a list of literals
alone, a vector's components, as one — so the same statement run with other
values is one line: how often it ran, its total, mean, least and most time,
the rows it returned or changed, and its failures. Over HTTP a
/query or /batch is counted as its FenecQL; a call
to a collection's path as its method and path. GET
/_stats/statements answers JSON, most time first, and
DELETE forgets it all.
The server keeps at most 5 000 shapes, as PostgreSQL's
pg_stat_statements does by default,
and forgets the least called when it is full. The counts need what reads the
data without a JWT's scope, or the admin token: a shape names every
collection. A tenant node keeps each tenant's apart —
/t/<tenant>/_stats/statements — and lists every
tenant's, named, to its admin alone.
The audit log
--audit <path> appends a JSON line for every refused
request, schema change and admin action. Ordinary reads and writes are
not logged.
fenec-server --file data.fenec --http 0.0.0.0:8080 \
--http-token "$TOKEN" --audit /var/log/fenec/audit.log- Refused tokens are slowed down, from 100 ms doubling to 5 s per
address (
--auth-delay). - No literal reaches the log: a schema change is logged by its shape.
Details
{"at":"2026-10-01T09:30:12.310Z","event":"schema","level":"info","proto":"http","peer":"10.0.0.7:53112","request_id":"5d0e7c2a91f4b366","statement":"create collection notes (title text)","failed":false}
{"at":"2026-10-01T09:32:05.117Z","event":"refused","level":"warn","proto":"http","peer":"198.51.100.4:40031","request_id":"b81f03c6d2e94a57","method":"GET","path":"/notes"}
{"at":"2026-10-01T09:40:00.550Z","event":"admin","level":"info","proto":"http","peer":"10.0.0.2:61200","request_id":"deploy-7","method":"POST","path":"/_shard/tenants/acme/move","http_status":200}--audit <path> appends one JSON line for each of these
events:
- An HTTP request refused for its token (401).
- A statement that changes the schema:
create,drop,alterorcompact. The statement is logged by its shape, as/_stats/statementsshows it, so no literal it held reaches the log. - A request to
/_admin/or/_shard/that changes something, with its status.fenec-shardtakes--auditas well.
Each line has the time, a level (warn for a refusal,
info otherwise), the protocol, the peer's address and the
request id; an admin request's status is
http_status, since a log collector takes a
status for the line's level. Reads and
writes are not logged, so the
log costs nothing on their path. Lines are written as the events happen and
are not synced: after a crash the log can lose its last lines, but the data
loses nothing.
A refused token waits before it is answered, the way PostgreSQL's
auth_delay works. The first failure from an address within a
minute waits --auth-delay (100 ms by default). Each further
failure from that address doubles the wait, up to 5 s. This applies to every
401, logged or not. A guessing client gets about one try every five seconds
per address. A request whose token the server verifies — its
--http-token, a JWT whose signature checks, the admin,
replication or scrape token, a router's own --token — waits
for nothing and clears its address's count, whatever the answer then says
of the rest of the request. Nothing else clears it: an answer given without
looking at a token — an unknown path, /_health, a preflight,
the studio's files, a body over the ceiling, every answer of a server that
asks for no token — leaves the count where it was, whatever
Authorization the request carried. The count is the
address's, not a token's: a client that holds one good token clears its
count with it. Behind fenec-shard the router waits out a
refusal itself, keyed by its client's address, and the node waits none for
what the router forwards: counted at the node, every client had the
router's address, four forged tokens in a row made a fifth client's expired
one wait 1.6 s, and any good token from anyone cleared the attacker's
count. The node knows a forwarded request by a mark the router derives
from the node's --admin-token, sent beside the client's address
(Fenec-Router: <address> <mark>); the router drops
the header from what a client sends, and a node believes it from no one
else. Its audit log names the client as peer and the router as
via. The router holds no keys: the node tells it, in its
answer, that it verified a forwarded request's token, and the router clears
its client's count on that alone. Behind another reverse proxy every client has the
proxy's address, so the proxy's own rate limit is the one that counts
there. --auth-delay 0 turns the wait off.
The router
fenec-shard serves its own /_metrics, behind its
--token: requests, the time its nodes took, failed nodes and
tenant moves. No label names a tenant.
curl -H "Authorization: Bearer $SHARD_TOKEN" http://127.0.0.1:8090/_metrics
# fenec_router_requests_total{route="tenant",code="2xx"} 48120
# fenec_router_upstream_duration_seconds_bucket{le="0.001"} 47655
# fenec_router_upstream_errors_total{node="n2"} 3
# ...| Metric | Type | What it says |
|---|---|---|
fenec_router_requests_total{route, code} | counter | requests answered; route is tenant (/t/…, forwarded), shard, replication, metrics or other, code the status class, 2xx to 5xx |
fenec_router_request_duration_seconds{route} | histogram | arrival to answer, in buckets from 100 µs to 10 s |
fenec_router_upstream_duration_seconds | histogram | a forwarded request's time at its node: from its sending to the whole answer. Taken from the request's duration, it leaves the router's own |
fenec_router_upstream_errors_total{node} | counter | forwarded requests a node did not answer or broke off, each answered 502: the one to alert on |
fenec_router_refused_total | counter | requests refused for their token (401): the router's own, and its nodes' it passed on |
fenec_router_moves_total{outcome} | counter | tenant moves that began, done or failed and thawed; one refused before it began is not one |
fenec_router_move_duration_seconds | histogram | how long a move that was done took, the tenant frozen for all of it, in buckets from 10 ms to 1 000 s |
fenec_router_connections | gauge | client connections open now, the scrape's own among them |
fenec_router_nodes, fenec_router_tenants{node} | gauge | the nodes in the directory, and the tenants placed on each |
fenec_router_replicas{node} | gauge | the replicas of their own each node holds, with --replicas; a node's count falls to 0 after a failover took the node's replicas away, until POST /_shard/replicas |
fenec_router_tenants_moving | gauge | tenants a move began on and did not record done — served from where they were, and cleared by moving them again |
fenec_router_following | gauge | 1 on a standby router; its directory's fenec_replica_* gauges follow, and a primary's fenec_replicas |
fenec_router_lease_age_seconds{node} | gauge | with --auto-failover, the seconds since each node last took its lease: renewed every third of a lease, and failed over past the lease and a tenth |
fenec_router_node_failed_over{node} | gauge | 1 for a node the router failed over on its own and that has not answered since; 0 again once it does and a repair has its copies follow |
fenec_router_auto_failovers_total{outcome} | counter | nodes failed over on their own, done when every tenant was promoted, failed when one was not |
Details
fenec-shard serves /_metrics on its own listener,
behind its --token as /_shard/ is: a scrape names the
nodes. It counts what it answered and what the nodes took over it; what a
tenant's statements did is its node's to count, and no label names a tenant.
A request is timed from its arrival to its answer, the node's time included —
a subscription to its head, since the stream lasts as long as its client
stays; a standby router's replication stream is counted and not timed.
# what the router adds, at the median, over five minutes
histogram_quantile(0.5, sum by (le) (rate(fenec_router_request_duration_seconds_bucket{route="tenant"}[5m])))
- histogram_quantile(0.5, sum by (le) (rate(fenec_router_upstream_duration_seconds_bucket[5m])))
# worth an alert
rate(fenec_router_upstream_errors_total[5m]) > 0
fenec_router_tenants_moving > 0
increase(fenec_router_auto_failovers_total[5m]) > 0
fenec_router_node_failed_over > 0Request ids
Every answer carries an X-Request-Id, and every line its
request wrote — the audit log's, the slow statement's, an error on stderr —
carries the same id. A client's own id is kept; one is made where none was
sent.
curl -si -H 'X-Request-Id: checkout-7f3a' http://127.0.0.1:8080/_health | grep -i request-id
# X-Request-Id: checkout-7f3aDetails
An id a client sends is kept when it is printable ASCII of at most 128
bytes; any other is replaced, as a header or a log line could not carry it
whole. A made one is 16 hex characters. fenec-shard takes the
client's id or makes one, sends it to the node, which takes it as its own,
and answers with it: one request through the router has one id in the
router's log, the node's and the answer. A text line on stderr written while
a request was served — a sync error — ends with
request_id=<id>. A subscription's and a replication
stream's head carry the id too. Making one and writing it cost a request
nothing measurable (make requests-bench, in turns).
Tracing
With --otlp-endpoint, fenec-server and fenec-shard send
OpenTelemetry traces: a span a request, with its waits under it — the
lock, the statement, the disk, the replicas. Any system that takes OTLP
shows where a request's time went: Datadog, Grafana Tempo, Honeycomb, New
Relic, Elastic.
# an OpenTelemetry Collector, or the Datadog Agent, on this host
fenec-server --file data.fenec --sync always \
--otlp-endpoint http://127.0.0.1:4318 --trace-sample 0.1
OTEL_SERVICE_NAME=orders-db fenec-server --file data.fenec \
--otlp-endpoint http://127.0.0.1:4318- A collector on loopback. fenecdb speaks no TLS, so the endpoint is a collector on the same host or network — the OpenTelemetry Collector, the Datadog Agent's OTLP receiver, Grafana Alloy — which forwards over TLS: their setup.
- W3C
traceparent. A request carrying one continues the caller's trace;fenec-shardcontinues it and hands it to the node, so the router's spans and the node's are one tree. - Off unless an endpoint is given, and free when off. A ratio samples the traces that start at fenecdb; a caller's sampled trace is always followed.
| Span | Kind | What it times |
|---|---|---|
POST /query, GET /{collection}, POST /t/{tenant}/batch… | server | the request, from its head read to its answer written: its method and route, never a value |
lock.wait | internal | the wait for the database's lock, fenec.lock read or write: a read waits out a write's block, a write every read |
execute | internal | the statement run, under the lock; a /batch's statements together, fenec.statements of them, and a create index or compact beside the database with fenec.beside |
durability | internal | a write's wait for the disk after the lock (--sync always); on a primary it holds fsync and replication.wait_sent, the wait for its replicas' streams to send the write |
fenec.wait_for_write | internal | a replica's wait for the write a Fenec-After names |
forward | client | fenec-shard's forward to the node, to its whole answer: the node's server span is its child |
Details
The server span carries OpenTelemetry's names where one fits:
http.request.method, http.route (a template:
/t/{tenant}/{collection}/near), url.path,
http.response.status_code, server.address and
server.port, client.address,
db.system (fenecdb),
db.operation.name (get, put,
create…) and, for FenecQL, db.query.text — the
statement's shape, each literal and parameter written $n, as
the statements' counts show it, so no value
reaches a span. A REST request's target holds values in its query string,
so it has its route alone. A --dir node's and the router's
spans carry fenec.tenant, the router's fenec.node,
and every server span fenec.request_id, the
request id its log lines carry. A 5xx marks the
server span an error; a 4xx is the client's and does not.
A request's traceparent is read as the W3C spec writes it;
one that does not parse starts a trace of its own, and
tracestate goes on unchanged. A trace starting at fenecdb is
kept when its id's low 56 bits fall under the ratio, OpenTelemetry's
TraceIdRatioBased, so a router and its nodes sampling at one
ratio keep the same traces. --trace-sample is 1 with an
endpoint; a busy server wants less. Health checks, scrapes, preflights and
the studio's files are no spans, and a subscription or a replication
stream, which lasts as long as its client, has none.
The endpoint is taken from --otlp-endpoint, then
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, both used as given unless
they name no path, then OTEL_EXPORTER_OTLP_ENDPOINT, under
which /v1/traces goes. --otlp-header k=v (and
OTEL_EXPORTER_OTLP_HEADERS) adds a header to every post, an
API key a collector asks for; OTEL_SERVICE_NAME names the
service (fenec-server, fenec-shard) and
OTEL_RESOURCE_ATTRIBUTES adds to it. Spans go as OTLP over
HTTP with JSON, POST /v1/traces with
application/json, the collector's port 4318: an
OTEL_EXPORTER_OTLP_PROTOCOL of grpc is refused
at the start rather than sent to a port that cannot read it, and an
https:// endpoint likewise.
A finished request's spans go into a queue of
OTEL_BSP_MAX_QUEUE_SIZE spans (2 048), and a thread of their
own posts it every OTEL_BSP_SCHEDULE_DELAY (1 s), or as soon
as OTEL_BSP_MAX_EXPORT_BATCH_SIZE (512) are in, a post at
most that many, each bounded by OTEL_EXPORTER_OTLP_TIMEOUT
(10 s) and kept alive between posts. A request never waits for the
collector: with the queue full its spans are dropped and counted, and a
post that fails drops its spans, logged once a minute at most. fenec-server posts the
last second's spans as it shuts down.
| Metric | Type | What it says |
|---|---|---|
fenec_trace_spans_exported_total | counter | spans the endpoint took |
fenec_trace_spans_dropped_total{reason} | counter | spans left out: queue_full, the collector not keeping up, or export_failed, a post refused or unanswered |
What it costs
Counting is close to free: a few nanoseconds a statement, and counting one by its shape takes a fraction of the time parsing it does.
Details
Counting a statement is a handful of relaxed atomic adds, into counters a
thread each: 6.7 ns a statement with eight threads counting at once. Into one
shared set of counters the same eight cost 720 ns each, the cache line moving
from core to core, which is why they are spread. The router counts a request
the same way, 13.3 ns with eight threads counting, beside the 17 µs it adds
to the request (make shard-bench). A scrape reads the gauges
under the database's shared lock, beside the readers and ahead of a writer
for as long as a count would be.
Tracing off is one relaxed atomic load at each hook: in
make requests-bench, six runs in turns against the server
without it, one client's round trip stayed 0.022, 0.033, 0.059 and 0.021
ms at the median for a row by id, a filter, a near and a put,
against 0.022, 0.034, 0.059 and 0.022, and eight clients' requests a second
inside the runs' own spread. On, every request sampled, to a collector on
the same machine: 0.022, 0.034, 0.060 and 0.024 ms, and eight clients 2 to 9%
fewer requests a second — the exporter's thread writing 250 000 spans a
second as JSON took a core of the eight the clients and the server shared.
A sampled request's spans cost it their timestamps and a few allocations;
the JSON and the post are the exporter's.
Counting it by its shape is a pass over its text into a buffer the thread
keeps, and an entry found in the thread's shard: 0.34 µs for a read of 87
bytes, 0.56 µs with eight threads counting at once — a sixth of the 2.2 µs
parsing it takes. It grows with the text: 5.6 µs for a near with
768 components written into it, whose parse takes 85
(make statements-bench). A shape is made only as far as the
1 000 bytes kept of it, so two statements alike that far are one line: shaped
whole, a put of 1 000 rows of 128-dim vectors spent 1.7 ms of
its 12.6 there.