Datadog and other monitoring systems
Everything a monitoring system needs from fenecdb is
three things it already reads: /_metrics in Prometheus's
format, logs written as JSON lines, and traces sent over OTLP. Datadog's
check, pipeline and dashboard are ready in the repository, and the Agent's
OTLP receiver takes the traces (Tracing).
Datadog in three steps
The repository's monitoring/datadog/ holds the Agent's
configuration, a logs pipeline and a dashboard.
# 1. the check and the logs: the Agent reads /_metrics and both logs
sudo cp monitoring/datadog/conf.yaml /etc/datadog-agent/conf.d/openmetrics.d/conf.yaml
sudo systemctl restart datadog-agent # with logs_enabled: true in datadog.yaml
# 2. the pipeline that remaps the logs' attributes
curl -X POST https://api.datadoghq.com/api/v1/logs/config/pipelines \
-H "DD-API-KEY: $DD_API_KEY" -H "DD-APPLICATION-KEY: $DD_APP_KEY" \
-H 'Content-Type: application/json' -d @monitoring/datadog/pipeline.json
# 3. the dashboard
curl -X POST https://api.datadoghq.com/api/v1/dashboard \
-H "DD-API-KEY: $DD_API_KEY" -H "DD-APPLICATION-KEY: $DD_APP_KEY" \
-H 'Content-Type: application/json' -d @monitoring/datadog/dashboard.json- Every server, replica, tenant node and router is an instance of the same check, each with its own address and token.
- The names are tested: a test scrapes a real server, a replica, a tenant node and a router, and fails if the check or the dashboard names a metric none of them sends.
The metrics
The Agent's OpenMetrics check reads /_metrics. With
namespace: fenecdb and the fenec_ prefix taken off,
the metrics arrive as fenecdb.*.
instances:
- openmetrics_endpoint: http://127.0.0.1:9464/_metrics
headers:
Authorization: "Bearer CHANGE-ME" # --admin-token or --http-token
namespace: fenecdb
raw_metric_prefix: fenec_
metrics:
- statements
- statement_errors
- statement_duration_seconds
- refused
- change_sequence
- ^replica_.*$
- ^tenants.*$
- ^router_.*$
# ... the whole list is in monitoring/datadog/conf.yaml
histogram_buckets_as_distributions: true
tags:
- service:fenecdb
- fenec_role:server # server, replica, tenant-node or routerIn /_metrics | In Datadog | Type |
|---|---|---|
fenec_statements_total{kind} | fenecdb.statements.count, tag kind | count: .as_rate() is a second's |
fenec_documents{collection} | fenecdb.documents, tag collection | gauge |
fenec_statement_duration_seconds | fenecdb.statement_duration_seconds.bucket, .sum, .count | distribution: p99: once percentiles are on |
fenec_router_requests_total{route, code} | fenecdb.router_requests.count, tags route, code | count |
Details
A counter loses its _total and arrives as a count,
fenecdb.<name>.count; a gauge arrives under its name. A
histogram's buckets arrive, with
histogram_buckets_as_distributions, as one distribution,
fenecdb.<name>.bucket, whose percentiles Datadog takes
over every server's buckets together — as Prometheus's
histogram_quantile does — once they are turned on in the
metric's summary; its .sum and .count give the
mean. Every label is a tag: kind, collection,
route, code, node,
outcome, version.
No label names a tenant. A tenant node publishes its tenants'
counts together (fenec_tenants, fenec_tenants_open,
fenec_tenants_disk_bytes): a tenant's collection names are not
the node's to publish, and a tag per tenant would be as many series as there
are customers, which Datadog bills by. So a node's role goes into the
instance's tags — fenec_role:tenant-node,
fenec_node:n1 — and a tenant's own figures come from the logs,
whose lines carry a tenant attribute, and from
/t/<tenant>/_stats/statements.
The token goes in the Authorization header: kept out of the
file with the Agent's secrets management ("Bearer
ENC[fenec_metrics_token]"), or under Autodiscovery as
"Bearer %%env_FENEC_METRICS_TOKEN%%". The metric names
fenecdb publishes are in Monitoring.
The logs
The audit log (--audit) and the slow statements
(--slow-ms, on stderr) are JSON lines, one event each, with the
same attributes. The Agent tails both files; the pipeline makes their
attributes Datadog's own.
{"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,"tenant":"acme","statement":"POST /query {\"query\":\"get people order name\"}"}
{"at":"2026-10-05T09:32:05.117Z","event":"refused","level":"warn","proto":"http","peer":"198.51.100.4:40031","request_id":"5d0e7c2a91f4b366","method":"GET","path":"/notes"}logs:
- type: file
path: /var/log/fenec/audit.log # --audit /var/log/fenec/audit.log
service: fenecdb
source: fenecdb
- type: file
path: /var/log/fenec/server.log # fenec-server ... 2>> /var/log/fenec/server.log
service: fenecdb
source: fenecdb| Attribute | Remapped to | By |
|---|---|---|
service | the log's service | the Agent's service: fenecdb |
level | the log's status: info, warn | status remapper |
at | the log's date | date remapper |
duration_ms | duration, in nanoseconds | arithmetic processor, duration_ms * 1000000 |
request_id | http.request_id, kept as it is too | attribute remapper |
peer | network.client.ip, network.client.port | grok parser |
method, path, http_status | http.method, http.url_details.path, http.status_code | attribute remappers |
Details
Every line has at, event
(slow, refused, schema or
admin), level, proto,
peer — with via, the router, where one forwarded
the request — and the request's request_id. A slow statement
adds duration_ms, kind (read or
write), failed, tenant on a tenant
node, and statement; a refusal method and
path; a schema change statement, by its shape,
tenant and failed; an admin request
method, path and http_status — not
status, which Datadog takes for the line's level before
level.
stderr also holds the server's own text lines — that it listens, what it
linked, an error. One written while a request was served ends with
request_id=<id>, which the pipeline's first processor, a
grok parser over the message, takes out. Under Docker, the container's log
is stderr: type: docker with the same source and
service, or the label
com.datadoghq.ad.logs='[{"source":"fenecdb","service":"fenecdb"}]'.
--slow-ms 0 logs every statement, as PostgreSQL's
log_min_duration_statement = 0 does: a pipeline is tried out on
what a server answers now. A test starts fenec-server, makes it
write each kind of line, and fails if the pipeline or the dashboard reads an
attribute none of them has. The audit
log and the slow statements are
described in Monitoring.
The dashboard
monitoring/datadog/dashboard.json opens on whether the disk
has refused a write, statements and errors a second, refusals and the memory
footprint, then requests and their latency, writes and storage,
replication, tenants, the router, and the logs.
Details
Its groups, each on the metrics /_metrics sends:
- Requests: statements a second by kind, errors, p50, p95 and p99 by kind, the mean, slow statements, refusals (401) and connections.
- Writes and storage: writes a second — the rate of
fenecdb.change_sequence— documents and each collection's dead share, the file and what a compact would give back, compactions--auto-compactran, and vectors waiting to be linked. - Replication: a replica's lag in writes, whether it is connected, since it last heard from its primary, a primary's replicas, reconnects and whole images.
- Tenants: on disk and open, their files, and the tenants placed on each node.
- Router: requests by route and status class, its p99 against its nodes', nodes it could not reach, moves and refusals.
- Logs: the slow statements, the refusals, and the schema changes and admin requests, each with its request id.
The host template variable picks one server or all of them.
The percentiles need the distribution's percentiles turned on, once, in the
metric's summary.
One id a request
Every answer carries X-Request-Id, and every line the
request wrote carries it as request_id: search the logs for the
id a client reports.
curl -i -H 'X-Request-Id: checkout-7f3a' -X POST http://127.0.0.1:8080/query \
-d '{"query":"get orders where id = 7"}'
# HTTP/1.1 200 OK
# X-Request-Id: checkout-7f3a- The client's id is kept when it is printable ASCII of at most 128 bytes; any other, or none, is replaced by 16 hex characters.
- Through
fenec-shardthe router hands its id to the node, so the router's lines, the node's and the answer have one id.
Details
A load balancer or a gateway that sets X-Request-Id has its
id carried through to fenecdb's logs; a client that sends none can read the
one made for it off the answer — a subscription's and a replication
stream's too — and report it. A made id is 64 bits of a mix of a key the
process reads from the system once, the connection's thread and its count of
requests: never the same twice within a process, and 15 ns to make, 12 to
keep a client's. The id lives with the connection's thread, as the audit log's peer
does, so nothing between the request's reading and a line's writing carries
it. Making it, sending it and logging it cost a request nothing measurable:
in make requests-bench, eight runs in turns, one client's
round trip for a row by id stayed 0.016–0.022 ms either way.
New Relic, Elastic, Grafana Cloud
Each reads the same two things: /_metrics as Prometheus
metrics, and the JSON lines as logs.
- New Relic: the infrastructure agent's Prometheus OpenMetrics
integration (
nri-prometheus) scrapes/_metricswith the token as a bearer header, and the metrics keep their names,fenec_statements_totaland the rest. Its log forwarder tails both files and parses each JSON line into attributes;request_idis one. - Elastic: Metricbeat's Prometheus module, its
collectormetricset, reads/_metrics(metrics_path: /_metrics, the token inheaders). Filebeat'sfilestreaminput with thendjsonparser reads the logs, and an ingest pipeline'sdateprocessor takesatas@timestampandlevelaslog.level. - Grafana Cloud: Grafana Alloy's
prometheus.scrapereads/_metricsandprometheus.remote_writesends it on — or a Prometheus of your own withremote_write— andmonitoring/grafana/fenecdb.jsonis the dashboard. Alloy'sloki.source.filewith astage.jsonsends the logs to Loki,request_idone of the fields.
Tracing
fenec-server and fenec-shard send OpenTelemetry traces to an OTLP endpoint: a span a request, and under it the wait for the lock, the statement, the disk and the replicas (the spans). fenecdb speaks no TLS, so it sends them to an agent or collector on the same host, which forwards them over TLS. The Datadog Agent takes them on its OTLP receiver:
# datadog.yaml: the Agent's OTLP receiver, HTTP on loopback
otlp_config:
receiver:
protocols:
http:
endpoint: localhost:4318
# in a container: DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT=0.0.0.0:4318sudo systemctl restart datadog-agent
OTEL_SERVICE_NAME=orders-db \
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod \
fenec-server --file data.fenec --otlp-endpoint http://localhost:4318 --trace-sample 0.1- APM shows the service
orders-db, its resources by route (POST /query), and each trace's waits;db.query.textis the statement's shape, with no value in it. - Traces join logs by
fenec.request_id, therequest_idof every line the request wrote. - A client's trace continues: a
traceparentfrom an instrumented app makes fenecdb's spans children of its call, and the router hands it on to the node. - Grafana Tempo: Alloy's
otelcol.receiver.otlpwithhttp { endpoint = "127.0.0.1:4318" }, sent on byotelcol.exporter.otlpto Tempo or Grafana Cloud. - Honeycomb: an OpenTelemetry Collector on loopback whose
otlphttpexporter sends tohttps://api.honeycomb.iowith thex-honeycomb-teamheader. - New Relic and Elastic take OTLP the same way, through a
collector's
otlphttpexporter tootlp.nr-data.netwith anapi-keyheader, or to an Elastic APM Server's OTLP intake.
Details
Spans go as OTLP over HTTP with JSON, port 4318; the Agent's gRPC port,
4317, is not for them. A header a collector asks for goes with
--otlp-header k=v or OTEL_EXPORTER_OTLP_HEADERS.
With no endpoint, tracing is off and costs nothing; on, a request never
waits for the Agent, and what the queue cannot hold is counted in
fenec_trace_spans_dropped_total
(Monitoring).