Metrics
Metrics are numbers your server reports about itself, so you can see how it behaves in production without reading logs: how long pushes take, how many devices are connected, how big the log is, why writes are refused. You plug them into whatever monitoring you already run (Prometheus, Grafana, Datadog, OpenTelemetry). Ferry measures; you decide where the numbers go.
Why you would want them
A sync server can look healthy while it is slowly getting worse: pushes taking twice as long, devices falling back to polling, a log growing without bound. Nothing fails, so nothing is logged. A number that you can graph and alert on is how you notice before your users do.
Ferry answers five questions:
- How long do pushes and pulls take?
- How many devices is the server holding a connection open for, and how do those end?
- How big is the log?
- Why are writes refused?
- What is retention removing?
Metrics are off by default. Without the metrics option, the server measures nothing and reads
no clock.
What a number is
You will meet three kinds:
- A counter only goes up: requests served, writes refused. You graph how fast it grows (a rate).
- A gauge is a value right now: connections open, entries in the log. You graph it as it is.
- A histogram records how long things took, in buckets, so you can ask "how long did the slowest 5% take?" (a percentile).
A tool such as Prometheus keeps these numbers over time. It scrapes
your server: every few seconds it fetches a page, here /metrics, and stores what it reads. Grafana
draws graphs from that, and alerts fire from it. If you use Datadog or OpenTelemetry instead, the
idea is the same.
Where each number is taken
Along a request
Your route and syncServer.fetch
auth, reading the body, a wrong method or endpoint
- ferry_rejections_total{source="http"}
The protocol server: push, pull, bootstrap
store, your hooks, the notice to other devices
- ferry_request_duration_seconds
- ferry_push_transactions_total
- ferry_rejections_total{source="transaction"}
The protocol server: wait
answered at once, or held until a push or the deadline
- ferry_waits_total
- ferry_waits_evicted_total
The amber box is what the latency histogram times: it starts and stops inside the protocol server.
Apart from any request
Held waits
one integer the server keeps; read when you scrape
- ferry_waits_held
The store, sampled
last sync id and floor, read together at most once per logSampleMs
- ferry_log_last_sync_id
- ferry_log_floor_sync_id
- ferry_log_entries
Retention, hourly at most
the floor before and after a prune
- ferry_retention_pruned_total
onError
every failure no request reports
- ferry_errors_total
Set it up
Ferry never imports a metrics library. You hand it a small object with three functions, counter,
histogram and gauge, that knows how to count. That object is the adapter. The one below
keeps the numbers in memory and prints them in the Prometheus text format, which Prometheus, Grafana
Agent and the OpenTelemetry Collector all read. With prom-client or OpenTelemetry you would write
the same three functions over their instruments.
-
Write the adapter once. Save it as
prometheus.tsnext to your server.prometheus.ts import type \{ MetricLabels, Metrics \} from 'ferrysync/server'; interface Family \{ name: string; help: string; kind: 'counter' | 'histogram'; /** Each line of the family, as text, with its current value. */ values: Map\<string, number>; \} const byName = ([a]: [string, string], [b]: [string, string]) => (a \< b ? -1 : 1); /** `ferry_waits_total{held="true",outcome="changed"}`, labels sorted, values made safe. */ const line = (name: string, labels: MetricLabels = \{\}) => \{ const text = Object.entries(labels) .sort(byName) .map(([label, value]) => `${label}="${value.replace(/["\\\n]/g, '_')}"`) .join(','); return text === '' ? name : `${name}{${text}}`; \}; const add = (values: Map\<string, number>, key: string, by: number) => \{ values.set(key, (values.get(key) ?? 0) + by); \}; export const createPrometheusMetrics = () => \{ const families: Family[] = []; const gauges: \{ name: string; help: string; observe: () => number | undefined | Promise\<number | undefined>; \}[] = []; const metrics: Metrics = \{ counter: (\{ name, help \}) => \{ const values = new Map\<string, number>(); families.push(\{ name, help, kind: 'counter', values \}); return (value, labels) => add(values, line(name, labels), value); \}, histogram: (\{ name, help, buckets = [0.01, 0.1, 1, 10] \}) => \{ const values = new Map\<string, number>(); families.push(\{ name, help, kind: 'histogram', values \}); return (value, labels) => \{ add(values, line(`${name}_count`, labels), 1); add(values, line(`${name}_sum`, labels), value); for (const bound of [...buckets, Infinity]) \{ const le = bound === Infinity ? '+Inf' : String(bound); add(values, line(`${name}_bucket`, \{ ...labels, le \}), value \<= bound ? 1 : 0); \} \}; \}, gauge: (\{ name, help \}, observe) => \{ gauges.push(\{ name, help, observe \}); \}, \}; /** The body of your `/metrics` route. */ const render = async () => \{ const lines: string[] = []; for (const \{ name, help, observe \} of gauges) \{ const value = await observe(); // No value (the store could not be read): say nothing, never a made-up zero. if (value !== undefined) \{ lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, `${name} ${value}`); \} \} for (const \{ name, help, kind, values \} of families) \{ lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${kind}`); for (const [text, value] of values) \{ lines.push(`${text} ${value}`); \} \} return `${lines.join('\n')}\n`; \}; return \{ metrics, render \}; \};You should see: nothing yet. It is a file that compiles.
-
Give the server the
metricshalf, and answer/metricswith therenderhalf.server.ts import \{ createSyncServer, memoryStore \} from 'ferrysync/server'; import \{ createPrometheusMetrics \} from './prometheus'; import \{ schema \} from './schema'; const prometheus = createPrometheusMetrics(); export const syncServer = createSyncServer(\{ schema, store: memoryStore(), auth: async (request) => (await getSession(request))?.userId ?? null, metrics: prometheus.metrics, \}); const metricsResponse = async () => new Response(await prometheus.render(), \{ headers: \{ 'content-type': 'text/plain; version=0.0.4' \}, \}); /** Every request: `/metrics` shows the numbers, anything else is for sync. */ export const handle = (request: Request) => new URL(request.url).pathname === '/metrics' ? metricsResponse() : syncServer.fetch(request);Serve
handlewith your runtime (Bun.serve({ fetch: handle }), for one), or move the two pieces into your framework's routes:syncServer.fetchwhere the sync route is, andmetricsResponseonGET /metrics.You should see: your server starts as before. Metrics add one function call per request.
-
Look at the numbers. Use the app, or push once from a script, then ask your server:
curl localhost:3000/metricsYou should see: text with
ferry_waits_held 0and the threeferry_log_*gauges (all0on an empty database). A counter or histogram has no line until something happens, so after a few requests you also get lines such asferry_request_duration_seconds_count{endpoint="push",outcome="ok"} 3. -
Point your monitoring at it. Tell Prometheus (or your agent) to fetch
/metricsevery 15 or 30 seconds. Keep that route off the public internet: the numbers say how busy you are. -
Alert on what matters. Three to start with, in PromQL:
histogram_quantile(0.95, sum by (le) (rate(ferry_request_duration_seconds_bucket\{endpoint="push"\}[5m]))) ferry_waits_held rate(ferry_rejections_total\{source="http",code="internal"\}[5m]) > 0- The first above your budget: pushes are getting slow.
- The second far below the number of devices you expect online: realtime has fallen back to polling.
- The third: the server is failing requests.
This adapter is a teaching sketch: one set of buckets for every histogram unless Ferry suggests
others, and no protection against a very large number of label values (Ferry's labels are all
bounded). A real one is a thin wrapper over prom-client, whose Counter, Histogram and Gauge
take the same names, help strings and label names that Ferry passes in the spec.
Every metric
All names start with ferry_ and follow the Prometheus conventions (snake case, _total for
counters, seconds). In OpenTelemetry, counter and histogram map to createCounter and
createHistogram, and gauge to createObservableGauge with observe as its callback.
| Name | Type | Labels | What it counts or shows |
|---|---|---|---|
ferry_request_duration_seconds | histogram | endpoint, outcome | How long push, pull and bootstrap take, in seconds |
ferry_push_transactions_total | counter | status | Each transaction of a push, by what it got |
ferry_rejections_total | counter | source, code | Transactions refused (source="transaction") and HTTP error answers (source="http") |
ferry_waits_held | gauge | none | Open wait requests this instance holds right now |
ferry_waits_total | counter | outcome, held | Waits answered, and whether they were ever held |
ferry_waits_evicted_total | counter | none | Held waits released because a user went past maxHeldWaitsPerUser |
ferry_log_last_sync_id | gauge | none | The highest sync id handed out |
ferry_log_floor_sync_id | gauge | none | The highest sync id retention has pruned |
ferry_log_entries | gauge | none | Entries the log keeps: ferry_log_last_sync_id - ferry_log_floor_sync_id |
ferry_retention_pruned_total | counter | what | Log entries retention pruned (what="log_entries") |
ferry_errors_total | counter | none | Calls to your onError, whatever their cause |
What the labels can be. Every set is fixed, so a metrics backend never grows without limit because
of Ferry (the one exception, code, is capped):
| Label | Values |
|---|---|
endpoint | push, pull, bootstrap |
outcome | push: ok, updateNeeded, forgottenClient, error; pull: actions, mustRefetch, updateNeeded, error; bootstrap: snapshot, updateNeeded, error; wait: changed, timeout |
status | applied, rejected, conflict, forgotten, replayed (a resent transaction, answered from its stored result) |
source | transaction, http |
code | source="http": the seven HTTP error codes. source="transaction": the engine's own codes, and the codes your validate hooks return (at most 32 of them, then other) |
held | true for a wait that was held, false for one answered at its first look |
what | log_entries |
Options
| Option | Default | What it does |
|---|---|---|
metrics | none | The adapter: an object with counter, histogram and gauge. Without it nothing is measured and no clock is read |
logSampleMs | 30000 | How long one reading of the log's size is reused by the three ferry_log_* gauges, in milliseconds (a positive integer) |
monotonic | performance.now | For tests: a clock in milliseconds that never steps back, used for latency. Falls back to now where there is no performance |
logSampleMs exists so that scraping often does not read your database often. The log's size is
last sync id - floor, two single-row reads, and Ferry takes them together at most once per period,
whatever the number of scrapes or gauges. If the read fails, the error goes to onError and the
three gauges report nothing for that scrape: your backend keeps its last value or shows a gap. Ferry
never invents a number.
Test it
memoryMetrics() in ferrysync/testing is an adapter that keeps everything in plain objects, so a
test can read what the server measured:
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { memoryMetrics } from 'ferrysync/testing';
import { schema } from './schema';
const recorded = memoryMetrics();
const server = createSyncServer({
schema,
store: memoryStore(),
auth: async () => 'user-1',
metrics: recorded.metrics,
});
// …a request…
recorded.count('ferry_request_duration_seconds', { endpoint: 'push', outcome: 'ok' });
await recorded.gauge('ferry_log_entries');
await server.close();Gotchas
- Several instances: sum the held waits, take the max of the log.
ferry_waits_heldcounts what this process holds, so add it up across instances. The log is one log in one database, so every instance reports the same size: usemax, neversum, or a three-instance deploy will look three times as full. timeoutis not always a deadline. It also counts a wait ended by a device that went away and one ended byclose(). Only evictions are told apart, inferry_waits_evicted_total. For a wait that was held, dividingchangedbytimeoutstill tells you whether realtime is working.- Latency stops inside the protocol server. It covers the store, your hooks and the notice sent
to other devices. It does not cover
auth, reading the body or your framework: measure the full HTTP time there. YourafterCommithooks count only when you did not passwaitUntil: without it they are awaited before the answer, so they are inside the time; with it they run after the answer and are not. - A held wait is not a latency. There is no histogram of how long waits are held, because
holding is their job. Use
ferry_waits_heldandferry_waits_total. - Rejection codes are capped at 32. The engine's own codes are always labels. A code your
validatehook invents counts as its own label up to 32 distinct ones per server, and every new one after that is counted undercode="other", so a bug that puts an id in a code cannot flood your metrics backend. - A metric that throws changes nothing. An instrument that throws, when Ferry declares it or
when it records into it, is reported to
onError(without counting it inferry_errors_total, or it could loop). The request is answered as if metrics were off. - Counters reset when the process restarts. That is normal: use
rate()orincrease(), not the raw value. On serverless, where instances come and go, a pull-based/metricsroute sees only the instance that answers; push the numbers from your adapter instead. - The log is a count, not bytes.
ferry_log_entriesdoes not say how much disk the log takes or how old its oldest entry is. Read those from your database.
Next
- Retention: how long the log is kept, and what
ferry_retention_pruned_totalcounts. - Realtime and scaling: what held waits are and why many of them is good.
Retention
The server keeps a log of every change, so each device can pull what it missed. Retention drops the log's old entries, the stored answers to pushes, and the devices that stopped syncing, by itself: nothing to schedule.
Realtime and scaling
Devices hear about each other's changes within a moment, through plain HTTP: no WebSockets, no extra service. With several server instances, one more option keeps it that fast.