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 router
In /_metricsIn DatadogType
fenec_statements_total{kind}fenecdb.statements.count, tag kindcount: .as_rate() is a second's
fenec_documents{collection}fenecdb.documents, tag collectiongauge
fenec_statement_duration_secondsfenecdb.statement_duration_seconds.bucket, .sum, .countdistribution: p99: once percentiles are on
fenec_router_requests_total{route, code}fenecdb.router_requests.count, tags route, codecount
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
AttributeRemapped toBy
servicethe log's servicethe Agent's service: fenecdb
levelthe log's status: info, warnstatus remapper
atthe log's datedate remapper
duration_msduration, in nanosecondsarithmetic processor, duration_ms * 1000000
request_idhttp.request_id, kept as it is tooattribute remapper
peernetwork.client.ip, network.client.portgrok parser
method, path, http_statushttp.method, http.url_details.path, http.status_codeattribute 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-compact ran, 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-shard the 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 /_metrics with the token as a bearer header, and the metrics keep their names, fenec_statements_total and the rest. Its log forwarder tails both files and parses each JSON line into attributes; request_id is one.
  • Elastic: Metricbeat's Prometheus module, its collector metricset, reads /_metrics (metrics_path: /_metrics, the token in headers). Filebeat's filestream input with the ndjson parser reads the logs, and an ingest pipeline's date processor takes at as @timestamp and level as log.level.
  • Grafana Cloud: Grafana Alloy's prometheus.scrape reads /_metrics and prometheus.remote_write sends it on — or a Prometheus of your own with remote_write — and monitoring/grafana/fenecdb.json is the dashboard. Alloy's loki.source.file with a stage.json sends the logs to Loki, request_id one 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:4318
sudo 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.text is the statement's shape, with no value in it.
  • Traces join logs by fenec.request_id, the request_id of every line the request wrote.
  • A client's trace continues: a traceparent from 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.otlp with http { endpoint = "127.0.0.1:4318" }, sent on by otelcol.exporter.otlp to Tempo or Grafana Cloud.
  • Honeycomb: an OpenTelemetry Collector on loopback whose otlphttp exporter sends to https://api.honeycomb.io with the x-honeycomb-team header.
  • New Relic and Elastic take OTLP the same way, through a collector's otlphttp exporter to otlp.nr-data.net with an api-key header, 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).