diff --git a/FEDERATION.md b/FEDERATION.md
new file mode 100644
index 0000000..aa4b88f
--- /dev/null
+++ b/FEDERATION.md
@@ -0,0 +1,198 @@
+# Federation protocol
+
+trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/),
+implemented with [Fedify](https://fedify.dev). This document describes the
+wire protocol precisely enough for another implementation to interoperate
+deliberately — actor discovery, the object and activity types we emit and
+accept, addressing, signatures, deduplication, delivery retry, and
+moderation. Examples use `trails.example` for our instance and
+`remote.example` for a peer.
+
+Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off,
+every federation surface returns 404 — a disabled instance is
+indistinguishable from one without the feature. Only users with
+`profile_visibility = 'public'` federate; a private user's actor,
+WebFinger, inbox, and outbox all 404, so their existence never leaks.
+
+## Actor discovery
+
+### WebFinger
+
+`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a
+handle to an actor IRI:
+
+```json
+{
+ "subject": "acct:alice@trails.example",
+ "links": [
+ { "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" }
+ ]
+}
+```
+
+### Actor
+
+`GET https://trails.example/users/alice` with `Accept: application/activity+json`
+returns a `Person`. The actor IRI and the human profile `url` are the same
+by design (browsers get HTML at that URL via content negotiation):
+
+```json
+{
+ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
+ "id": "https://trails.example/users/alice",
+ "type": "Person",
+ "preferredUsername": "alice",
+ "name": "Alice",
+ "summary": "trail runner",
+ "url": "https://trails.example/users/alice",
+ "inbox": "https://trails.example/users/alice/inbox",
+ "outbox": "https://trails.example/users/alice/outbox",
+ "publicKey": {
+ "id": "https://trails.example/users/alice#main-key",
+ "owner": "https://trails.example/users/alice",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
+ },
+ "assertionMethod": [ { "type": "Multikey", "…": "…" } ],
+ "attachment": [
+ { "type": "PropertyValue", "name": "🥾 trails.cool", "value": "trails.example/users/alice" },
+ { "type": "PropertyValue", "name": "Instance", "value": "trails.example" }
+ ]
+}
+```
+
+`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification;
+`assertionMethod` carries the same keys as Multikeys for newer stacks.
+
+### NodeInfo (software discovery)
+
+`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`:
+
+```json
+{
+ "version": "2.1",
+ "software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" },
+ "protocols": ["activitypub"],
+ "usage": { "users": {}, "localPosts": 0, "localComments": 0 }
+}
+```
+
+`software.name` is the machine-readable "this is a trails instance" marker
+used by the trails-to-trails outbound check. Usage counts are deliberately
+zeroed — publishing per-instance counts is a privacy decision we have not
+made.
+
+## Objects and activities
+
+Activities correspond to a user's journal entries. The object model is
+deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content`
+summarizes the activity and whose `url` links to the journal detail page.
+(A first-class `trails:Route` object type is planned with route-federation;
+today everything is a Note.)
+
+### Note
+
+```json
+{
+ "id": "https://trails.example/activities/01H…",
+ "type": "Note",
+ "attributedTo": "https://trails.example/users/alice",
+ "content": "
Morning trail run — 12.4 km, 480 m up
",
+ "url": "https://trails.example/activities/01H…",
+ "published": "2026-07-13T07:12:00Z",
+ "to": ["https://www.w3.org/ns/activitystreams#Public"]
+}
+```
+
+The Note IRI (`/activities/`) is dereferenceable and serves
+`application/activity+json` — Mastodon's search-fetch and strict re-fetch
+of pushed objects both rely on this.
+
+### Create / Delete
+
+A publish is a `Create` wrapping the Note; the activity id is the object IRI
+with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone`
+at the same object IRI:
+
+```json
+{ "id": "https://trails.example/activities/01H…#create", "type": "Create",
+ "actor": "https://trails.example/users/alice",
+ "object": { "…": "the Note above" },
+ "published": "2026-07-13T07:12:00Z",
+ "to": ["https://www.w3.org/ns/activitystreams#Public"] }
+```
+
+Note that a `Delete` poisons the object URI on the remote forever (remotes
+tombstone it); re-publishing the same URI after a Delete is silently
+refused by strict remotes.
+
+### Follow graph
+
+The inbox is **narrow** — only follow-graph activities are processed;
+anything else is acknowledged (`202`) and dropped.
+
+| Inbound | Effect |
+|---|---|
+| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` |
+| `Undo(Follow)` | removes the follow |
+| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll |
+| `Reject(Follow)` | drops our pending outgoing Follow |
+
+## Addressing
+
+Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public`
+and **push-delivered** to each accepted remote follower's inbox (fan-out,
+one delivery per follower). We do not implement shared-inbox delivery.
+Remotes do not backfill history — only pushed or individually-fetched
+objects appear on a peer.
+
+## Signatures
+
+All inbound activities must carry a valid HTTP Signature; Fedify verifies it
+against the sending actor's `publicKey` (fetched and cached). Unsigned or
+badly-signed requests are rejected. Outbound deliveries are signed with the
+sending user's key. An actor changing keys requires the remote to re-fetch
+the actor document.
+
+## Deduplication
+
+Delivery is at-least-once, so receivers must be idempotent. trails dedups
+inbound activities two ways:
+
+- **`Create(Note)`** — idempotent via a unique constraint on the activity's
+ origin IRI (`remote_origin_iri`); a redelivered Create is a no-op.
+- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the
+ activity IRI is recorded in `federation_processed_activities` on first
+ receipt (insert-or-drop before any side effect); a redelivery is dropped
+ and counted (`federation_inbox_dropped_total{reason="duplicate"}`).
+ Records are retained ≥ 30 days, which comfortably exceeds the
+ HTTP-Signature date-freshness window, then swept.
+
+## Delivery retry
+
+Delivery queueing and retry state are **durable** — they survive process
+restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the
+retry policy). On a `5xx` or timeout, a delivery retries with exponential
+backoff, giving up after a bounded budget (~8 attempts spanning roughly a
+day) before a permanent failure is logged. Deliveries are paced to at most
+1 request/second per remote host. Metrics:
+`federation_delivery_total{outcome}` and `federation_queue_depth`.
+
+## Moderation
+
+An operator can block a federation instance by domain (exact-host match).
+A blocked instance is **inert in both directions**:
+
+- its inbound activities are silently dropped (`202`, no error oracle) and
+ counted (`federation_inbox_dropped_total{reason="blocked"}`);
+- it receives no deliveries (blocked recipients are filtered from fan-out);
+- we never fetch its actors or outboxes.
+
+Blocking is effective immediately (checked per request / per job, no cache).
+The operator procedure (a SQL insert/delete against
+`journal.federation_blocked_instances`) is documented in the
+[deployment runbook](docs/deployment.md#blocking-an-instance).
+
+---
+
+*Kept current as federation capabilities change. Specs:
+`openspec/specs/social-federation` and `openspec/specs/federation-operations`.*
diff --git a/README.md b/README.md
index 36f7493..1bc4215 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,14 @@ docker compose up -d
See [docs/architecture.md](docs/architecture.md) for details on self-hosting
configuration.
+## Federation
+
+The Journal federates over ActivityPub. The wire protocol — actor
+discovery, object/activity types with JSON examples, addressing,
+signatures, deduplication, delivery retry, and moderation — is documented
+in [FEDERATION.md](FEDERATION.md), which is precise enough for another
+implementation to interoperate against.
+
## Philosophy
- **Privacy by design** — The Planner collects zero user data
diff --git a/apps/journal/app/jobs/deliver-activity.ts b/apps/journal/app/jobs/deliver-activity.ts
index b75631c..30eb001 100644
--- a/apps/journal/app/jobs/deliver-activity.ts
+++ b/apps/journal/app/jobs/deliver-activity.ts
@@ -15,6 +15,7 @@ import {
type DeliveryPayload,
} from "../lib/federation-delivery.server.ts";
import { logger } from "../lib/logger.server.ts";
+import { federationDeliveryTotal } from "../lib/metrics.server.ts";
/**
* Outbound pacing (spec 5.5): never exceed 1 request/second per remote
@@ -46,8 +47,10 @@ export const deliverActivityJob = defineJournalJob({
for (const job of jobs) {
const p = job.data;
try {
- await deliverOne(p);
+ const outcome = await deliverOne(p);
+ federationDeliveryTotal.inc({ outcome });
} catch (err) {
+ federationDeliveryTotal.inc({ outcome: "failed" });
logger.warn(
{ err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity attempt failed (pg-boss will retry until budget exhausted)",
@@ -58,7 +61,7 @@ export const deliverActivityJob = defineJournalJob({
},
});
-async function deliverOne(p: DeliveryPayload): Promise {
+async function deliverOne(p: DeliveryPayload): Promise<"delivered" | "skipped"> {
const federation = getFederation();
const ctx = federation.createContext(new URL(getOrigin()), undefined);
@@ -75,7 +78,7 @@ async function deliverOne(p: DeliveryPayload): Promise {
.limit(1);
if (!row) {
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
- return;
+ return "skipped";
}
// Spec 9.3: flipping the profile to private stops federation — also
// for deliveries already enqueued when the flip happened.
@@ -86,7 +89,7 @@ async function deliverOne(p: DeliveryPayload): Promise {
.limit(1);
if (!owner || owner.profileVisibility !== "public") {
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
- return;
+ return "skipped";
}
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
} else {
@@ -127,4 +130,5 @@ async function deliverOne(p: DeliveryPayload): Promise {
{ action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity: delivered",
);
+ return "delivered";
}
diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts
index 9581ea5..b93b8ac 100644
--- a/apps/journal/app/lib/federation.server.ts
+++ b/apps/journal/app/lib/federation.server.ts
@@ -38,6 +38,7 @@ import { PostgresKvStore } from "./federation-kv.server.ts";
import { PgBossMessageQueue } from "./federation-queue.server.ts";
import { markInboundActivityProcessed } from "./federation-replay.server.ts";
import { isBlockedIri } from "./federation-blocklist.server.ts";
+import { federationInboxDroppedTotal } from "./metrics.server.ts";
import { ensureUserKeypair, loadUserKeypair } from "./federation-keys.server.ts";
import { activityToCreate, activityToNote } from "./federation-objects.server.ts";
import {
@@ -273,8 +274,8 @@ function buildFederation(): Federation {
// when the local target is public; otherwise drop (the actor
// already 404s for private users).
if (follow.id == null || follow.actorId == null || follow.objectId == null) return;
- if (await isBlockedIri(follow.actorId.href)) return; // blocked instance: silent 202 drop
- if (!(await markInboundActivityProcessed(follow.id.href)).fresh) return; // replay: drop
+ if (await isBlockedIri(follow.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop
+ if (!(await markInboundActivityProcessed(follow.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop
const parsed = ctx.parseUri(follow.objectId);
if (parsed?.type !== "actor") return;
const { outcome } = await recordRemoteFollow(follow.actorId.href, parsed.identifier);
@@ -295,8 +296,8 @@ function buildFederation(): Federation {
// Spec 4.3: Undo(Follow) removes the follow row. Other Undos are
// acknowledged and dropped.
if (undo.actorId == null) return;
- if (await isBlockedIri(undo.actorId.href)) return; // blocked instance: silent 202 drop
- if (undo.id != null && !(await markInboundActivityProcessed(undo.id.href)).fresh) return; // replay: drop
+ if (await isBlockedIri(undo.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop
+ if (undo.id != null && !(await markInboundActivityProcessed(undo.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop
const undoObjectId = undo.objectId; // capture before dereference (see Accept)
const object = await undo.getObject(ctx);
if (object instanceof Follow && object.objectId != null) {
@@ -325,8 +326,8 @@ function buildFederation(): Federation {
// Spec 4.4: a remote accepted our outgoing Follow — settle the
// Pending row and trigger the first outbox poll for that actor.
if (accept.actorId == null) return;
- if (await isBlockedIri(accept.actorId.href)) return; // blocked instance: silent 202 drop
- if (accept.id != null && !(await markInboundActivityProcessed(accept.id.href)).fresh) return; // replay: drop
+ if (await isBlockedIri(accept.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop
+ if (accept.id != null && !(await markInboundActivityProcessed(accept.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop
// Capture the raw object reference BEFORE dereferencing:
// getObject() memoizes the fetched document, after which objectId
// reports the fetched object's id (fragment stripped) instead of
@@ -366,8 +367,8 @@ function buildFederation(): Federation {
.on(Reject, async (ctx, reject) => {
// Spec 4.5: remote refused our Follow — drop the Pending row.
if (reject.actorId == null) return;
- if (await isBlockedIri(reject.actorId.href)) return; // blocked instance: silent 202 drop
- if (reject.id != null && !(await markInboundActivityProcessed(reject.id.href)).fresh) return; // replay: drop
+ if (await isBlockedIri(reject.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop
+ if (reject.id != null && !(await markInboundActivityProcessed(reject.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop
const objectId = reject.objectId; // capture before dereference (see Accept)
const object = await reject.getObject(ctx);
let localUser: Awaited> = null;
diff --git a/apps/journal/app/lib/metrics.server.ts b/apps/journal/app/lib/metrics.server.ts
index 468693a..9ea2181 100644
--- a/apps/journal/app/lib/metrics.server.ts
+++ b/apps/journal/app/lib/metrics.server.ts
@@ -48,6 +48,42 @@ export const demoBotSyntheticActivitiesTotal = getOrCreate(
}),
);
+// --- Federation metrics (spec: federation-operations "Federation delivery
+// observability") ---------------------------------------------------------
+
+/** Outbound delivery attempts by outcome (delivered | skipped | failed). */
+export const federationDeliveryTotal = getOrCreate(
+ "federation_delivery_total",
+ () =>
+ new client.Counter({
+ name: "federation_delivery_total",
+ help: "Outbound federation delivery attempts by outcome",
+ labelNames: ["outcome"] as const,
+ }),
+);
+
+/** Inbound activities dropped, by reason (duplicate | blocked). */
+export const federationInboxDroppedTotal = getOrCreate(
+ "federation_inbox_dropped_total",
+ () =>
+ new client.Counter({
+ name: "federation_inbox_dropped_total",
+ help: "Inbound federation activities dropped before side effects, by reason",
+ labelNames: ["reason"] as const,
+ }),
+);
+
+/** Messages waiting in the durable Fedify queue. Set at scrape time by the
+ * metrics route (the restart-loss regression detector). */
+export const federationQueueDepth = getOrCreate(
+ "federation_queue_depth",
+ () =>
+ new client.Gauge({
+ name: "federation_queue_depth",
+ help: "Messages waiting in the durable Fedify (pg-boss) message queue",
+ }),
+);
+
export const registry = client.register;
// --- Route label normalization -------------------------------------------
diff --git a/apps/journal/app/routes/api.metrics.ts b/apps/journal/app/routes/api.metrics.ts
index 852a3d2..f504738 100644
--- a/apps/journal/app/routes/api.metrics.ts
+++ b/apps/journal/app/routes/api.metrics.ts
@@ -1,6 +1,19 @@
-import { registry } from "~/lib/metrics.server";
+import { registry, federationQueueDepth } from "~/lib/metrics.server";
export async function loader() {
+ // Sample the durable Fedify queue depth at scrape time (the
+ // restart-loss regression detector). Best-effort: if federation is off
+ // or the boss isn't up yet, leave the last value rather than fail the
+ // scrape.
+ if (process.env.FEDERATION_ENABLED === "true") {
+ try {
+ const { PgBossMessageQueue } = await import("~/lib/federation-queue.server");
+ const depth = await new PgBossMessageQueue().getDepth();
+ federationQueueDepth.set(depth.queued);
+ } catch {
+ // boss not initialized / transient DB error — keep the prior gauge value
+ }
+ }
const metrics = await registry.metrics();
return new Response(metrics, {
headers: { "Content-Type": registry.contentType },
diff --git a/docs/architecture.md b/docs/architecture.md
index ef47b4a..fc52392 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -179,6 +179,11 @@ Tech stack:
## ActivityPub Integration
+The concrete wire protocol — actor discovery, object/activity types with
+JSON examples, addressing, signatures, deduplication, delivery retry, and
+moderation — is documented in [FEDERATION.md](../FEDERATION.md) at the repo
+root. This section is the higher-level design intent.
+
### Federated Activities
- `Create` Route - Publishing a new route
diff --git a/infrastructure/grafana/dashboards/journal.json b/infrastructure/grafana/dashboards/journal.json
index 6e53e93..39bb7d0 100644
--- a/infrastructure/grafana/dashboards/journal.json
+++ b/infrastructure/grafana/dashboards/journal.json
@@ -1,312 +1,391 @@
{
- "title": "Journal",
- "uid": "trails-journal",
- "annotations": {
- "list": [
- {
- "name": "Deploys",
- "enable": true,
- "datasource": {
- "type": "grafana",
- "uid": "-- Grafana --"
- },
- "iconColor": "rgba(0, 211, 255, 1)",
- "target": {
- "limit": 100,
- "matchAny": false,
- "tags": [
- "deploy"
- ],
- "type": "tags"
- }
- }
- ]
- },
- "timezone": "browser",
- "refresh": "30s",
- "panels": [
- {
- "title": "Caddy 502 Rate (Journal)",
- "description": "502 errors returned by Caddy for the journal upstream. These indicate Caddy could not reach the journal container.",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 0,
- "y": 0
- },
- "targets": [
- {
- "expr": "sum(rate(caddy_http_request_duration_seconds_count{server=~\".*trails.cool.*\", code=\"502\"}[5m])) or vector(0)",
- "legendFormat": "502/s"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "custom": {
- "fillOpacity": 20,
- "lineWidth": 2
- },
- "color": {
- "mode": "fixed",
- "fixedColor": "red"
- }
- }
- }
+ "title": "Journal",
+ "uid": "trails-journal",
+ "annotations": {
+ "list": [
+ {
+ "name": "Deploys",
+ "enable": true,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
},
- {
- "title": "Caddy Response Codes (Journal)",
- "description": "All HTTP response codes from Caddy for the journal host, including Caddy-generated errors like 502.",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 12,
- "y": 0
- },
- "targets": [
- {
- "expr": "sum(rate(caddy_http_request_duration_seconds_count{server=~\".*trails.cool.*\", code!~\"(101|2..|3..)\"}[5m])) by (code)",
- "legendFormat": "{{code}}"
- }
- ]
- },
- {
- "title": "Journal Request Rate by Status",
- "description": "Request rate from the journal app's own metrics, grouped by HTTP status code.",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 0,
- "y": 8
- },
- "targets": [
- {
- "expr": "sum(rate(http_request_duration_seconds_count{job=\"journal\"}[5m])) by (status)",
- "legendFormat": "{{status}}"
- }
- ]
- },
- {
- "title": "Journal Latency p50 / p95 / p99",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 12,
- "y": 8
- },
- "targets": [
- {
- "expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{job=\"journal\"}[5m])) by (le))",
- "legendFormat": "p50"
- },
- {
- "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"journal\"}[5m])) by (le))",
- "legendFormat": "p95"
- },
- {
- "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job=\"journal\"}[5m])) by (le))",
- "legendFormat": "p99"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "unit": "s"
- }
- }
- },
- {
- "title": "Request Rate by Route",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 24,
- "x": 0,
- "y": 16
- },
- "targets": [
- {
- "expr": "sum(rate(http_request_duration_seconds_count{job=\"journal\"}[5m])) by (route)",
- "legendFormat": "{{route}}"
- }
- ]
- },
- {
- "title": "Container Restarts",
- "description": "Journal container restart count. Spikes here correlate with 502 errors — Caddy returns 502 while the container is restarting.",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 8,
- "x": 0,
- "y": 24
- },
- "targets": [
- {
- "expr": "changes(container_start_time_seconds{name=\"trails-cool-journal-1\"}[5m])",
- "legendFormat": "journal"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "custom": {
- "fillOpacity": 30,
- "drawStyle": "bars"
- },
- "color": {
- "mode": "fixed",
- "fixedColor": "orange"
- }
- }
- }
- },
- {
- "title": "Container Memory",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 8,
- "x": 8,
- "y": 24
- },
- "targets": [
- {
- "expr": "container_memory_usage_bytes{name=\"trails-cool-journal-1\"}",
- "legendFormat": "used"
- },
- {
- "expr": "container_memory_working_set_bytes{name=\"trails-cool-journal-1\"}",
- "legendFormat": "working set"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "unit": "bytes"
- }
- }
- },
- {
- "title": "Container CPU",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 8,
- "x": 16,
- "y": 24
- },
- "targets": [
- {
- "expr": "rate(container_cpu_usage_seconds_total{name=\"trails-cool-journal-1\"}[5m]) * 100",
- "legendFormat": "CPU %"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "unit": "percent"
- }
- }
- },
- {
- "title": "Node.js Event Loop Lag",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 0,
- "y": 32
- },
- "targets": [
- {
- "expr": "nodejs_eventloop_lag_seconds{job=\"journal\"}",
- "legendFormat": "lag"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "unit": "s"
- }
- }
- },
- {
- "title": "Node.js Heap Used",
- "type": "timeseries",
- "gridPos": {
- "h": 8,
- "w": 12,
- "x": 12,
- "y": 32
- },
- "targets": [
- {
- "expr": "nodejs_heap_size_used_bytes{job=\"journal\"}",
- "legendFormat": "used"
- },
- {
- "expr": "nodejs_heap_size_total_bytes{job=\"journal\"}",
- "legendFormat": "total"
- }
- ],
- "fieldConfig": {
- "defaults": {
- "unit": "bytes"
- }
- }
- },
- {
- "title": "Journal Logs (Errors & Warnings)",
- "description": "Recent error and warning logs from the journal container. Requires Promtail docker_sd_configs with Pino JSON parsing.",
- "type": "logs",
- "gridPos": {
- "h": 10,
- "w": 24,
- "x": 0,
- "y": 40
- },
- "targets": [
- {
- "datasource": {
- "type": "loki",
- "uid": "loki"
- },
- "expr": "{service=\"journal\"} |~ \"(error|warn|ERR|WARN|level.*(40|50|60))\"",
- "legendFormat": ""
- }
- ],
- "options": {
- "showTime": true,
- "sortOrder": "Descending",
- "enableLogDetails": true,
- "dedupStrategy": "none"
- }
- },
- {
- "title": "Caddy Logs (5xx Responses)",
- "description": "Caddy access log entries with 5xx status codes. Shows the raw request that triggered the error.",
- "type": "logs",
- "gridPos": {
- "h": 10,
- "w": 24,
- "x": 0,
- "y": 50
- },
- "targets": [
- {
- "datasource": {
- "type": "loki",
- "uid": "loki"
- },
- "expr": "{service=\"caddy\"} |~ \"\\\"status\\\":\\s*5\\d\\d\"",
- "legendFormat": ""
- }
- ],
- "options": {
- "showTime": true,
- "sortOrder": "Descending",
- "enableLogDetails": true,
- "dedupStrategy": "none"
- }
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "target": {
+ "limit": 100,
+ "matchAny": false,
+ "tags": [
+ "deploy"
+ ],
+ "type": "tags"
}
+ }
]
+ },
+ "timezone": "browser",
+ "refresh": "30s",
+ "panels": [
+ {
+ "title": "Caddy 502 Rate (Journal)",
+ "description": "502 errors returned by Caddy for the journal upstream. These indicate Caddy could not reach the journal container.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(caddy_http_request_duration_seconds_count{server=~\".*trails.cool.*\", code=\"502\"}[5m])) or vector(0)",
+ "legendFormat": "502/s"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 20,
+ "lineWidth": 2
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "red"
+ }
+ }
+ }
+ },
+ {
+ "title": "Caddy Response Codes (Journal)",
+ "description": "All HTTP response codes from Caddy for the journal host, including Caddy-generated errors like 502.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 0
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(caddy_http_request_duration_seconds_count{server=~\".*trails.cool.*\", code!~\"(101|2..|3..)\"}[5m])) by (code)",
+ "legendFormat": "{{code}}"
+ }
+ ]
+ },
+ {
+ "title": "Journal Request Rate by Status",
+ "description": "Request rate from the journal app's own metrics, grouped by HTTP status code.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(http_request_duration_seconds_count{job=\"journal\"}[5m])) by (status)",
+ "legendFormat": "{{status}}"
+ }
+ ]
+ },
+ {
+ "title": "Journal Latency p50 / p95 / p99",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket{job=\"journal\"}[5m])) by (le))",
+ "legendFormat": "p50"
+ },
+ {
+ "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"journal\"}[5m])) by (le))",
+ "legendFormat": "p95"
+ },
+ {
+ "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job=\"journal\"}[5m])) by (le))",
+ "legendFormat": "p99"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ }
+ }
+ },
+ {
+ "title": "Request Rate by Route",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(http_request_duration_seconds_count{job=\"journal\"}[5m])) by (route)",
+ "legendFormat": "{{route}}"
+ }
+ ]
+ },
+ {
+ "title": "Container Restarts",
+ "description": "Journal container restart count. Spikes here correlate with 502 errors \u2014 Caddy returns 502 while the container is restarting.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 0,
+ "y": 24
+ },
+ "targets": [
+ {
+ "expr": "changes(container_start_time_seconds{name=\"trails-cool-journal-1\"}[5m])",
+ "legendFormat": "journal"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 30,
+ "drawStyle": "bars"
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "orange"
+ }
+ }
+ }
+ },
+ {
+ "title": "Container Memory",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 8,
+ "y": 24
+ },
+ "targets": [
+ {
+ "expr": "container_memory_usage_bytes{name=\"trails-cool-journal-1\"}",
+ "legendFormat": "used"
+ },
+ {
+ "expr": "container_memory_working_set_bytes{name=\"trails-cool-journal-1\"}",
+ "legendFormat": "working set"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes"
+ }
+ }
+ },
+ {
+ "title": "Container CPU",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 16,
+ "y": 24
+ },
+ "targets": [
+ {
+ "expr": "rate(container_cpu_usage_seconds_total{name=\"trails-cool-journal-1\"}[5m]) * 100",
+ "legendFormat": "CPU %"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percent"
+ }
+ }
+ },
+ {
+ "title": "Node.js Event Loop Lag",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 32
+ },
+ "targets": [
+ {
+ "expr": "nodejs_eventloop_lag_seconds{job=\"journal\"}",
+ "legendFormat": "lag"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ }
+ }
+ },
+ {
+ "title": "Node.js Heap Used",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 32
+ },
+ "targets": [
+ {
+ "expr": "nodejs_heap_size_used_bytes{job=\"journal\"}",
+ "legendFormat": "used"
+ },
+ {
+ "expr": "nodejs_heap_size_total_bytes{job=\"journal\"}",
+ "legendFormat": "total"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes"
+ }
+ }
+ },
+ {
+ "title": "Federation Delivery Rate by Outcome",
+ "description": "Outbound federation delivery attempts/sec by outcome (delivered/skipped/failed). Sustained 'failed' means an unreachable or misconfigured remote; retries continue until the budget is exhausted.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 40
+ },
+ "targets": [
+ {
+ "expr": "sum by (outcome) (rate(federation_delivery_total[5m]))",
+ "legendFormat": "{{outcome}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 20,
+ "lineWidth": 2
+ }
+ }
+ }
+ },
+ {
+ "title": "Federation Queue Depth",
+ "description": "Messages waiting in the durable Fedify (pg-boss) queue. A depth that climbs and never drains is the restart-loss / stuck-consumer regression detector \u2014 before the pg-boss backing (PR #570) a restart silently zeroed this by dropping the work.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 40
+ },
+ "targets": [
+ {
+ "expr": "federation_queue_depth",
+ "legendFormat": "queued"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 10,
+ "lineWidth": 2
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "blue"
+ }
+ }
+ }
+ },
+ {
+ "title": "Federation Inbox Drops by Reason",
+ "description": "Inbound activities dropped before side effects, by reason: 'duplicate' (replay defense) and 'blocked' (instance blocklist). A spike in 'blocked' after a block confirms it is taking effect; sustained 'duplicate' can indicate a misbehaving remote redelivering.",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 48
+ },
+ "targets": [
+ {
+ "expr": "sum by (reason) (rate(federation_inbox_dropped_total[5m]))",
+ "legendFormat": "{{reason}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 20,
+ "lineWidth": 2
+ }
+ }
+ }
+ },
+ {
+ "title": "Journal Logs (Errors & Warnings)",
+ "description": "Recent error and warning logs from the journal container. Requires Promtail docker_sd_configs with Pino JSON parsing.",
+ "type": "logs",
+ "gridPos": {
+ "h": 10,
+ "w": 24,
+ "x": 0,
+ "y": 56
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "expr": "{service=\"journal\"} |~ \"(error|warn|ERR|WARN|level.*(40|50|60))\"",
+ "legendFormat": ""
+ }
+ ],
+ "options": {
+ "showTime": true,
+ "sortOrder": "Descending",
+ "enableLogDetails": true,
+ "dedupStrategy": "none"
+ }
+ },
+ {
+ "title": "Caddy Logs (5xx Responses)",
+ "description": "Caddy access log entries with 5xx status codes. Shows the raw request that triggered the error.",
+ "type": "logs",
+ "gridPos": {
+ "h": 10,
+ "w": 24,
+ "x": 0,
+ "y": 66
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "expr": "{service=\"caddy\"} |~ \"\\\"status\\\":\\s*5\\d\\d\"",
+ "legendFormat": ""
+ }
+ ],
+ "options": {
+ "showTime": true,
+ "sortOrder": "Descending",
+ "enableLogDetails": true,
+ "dedupStrategy": "none"
+ }
+ }
+ ]
}
diff --git a/openspec/changes/federation-hardening/tasks.md b/openspec/changes/federation-hardening/tasks.md
index 292fb4d..89a0670 100644
--- a/openspec/changes/federation-hardening/tasks.md
+++ b/openspec/changes/federation-hardening/tasks.md
@@ -18,8 +18,8 @@
## 4. Protocol doc & observability
-- [ ] 4.1 Write `FEDERATION.md` (repo root): NodeInfo, actors/WebFinger, object + activity types with JSON examples, addressing, signatures + dedup expectations, retry policy, moderation semantics; link from README and docs
-- [ ] 4.2 Add `federation_delivery_total{outcome}`, `federation_queue_depth`, `federation_inbox_dropped_total{reason}` metrics + journal dashboard row
+- [x] 4.1 Write `FEDERATION.md` (repo root): NodeInfo, actors/WebFinger, object + activity types with JSON examples, addressing, signatures + dedup expectations, retry policy, moderation semantics; link from README and docs
+- [x] 4.2 Add `federation_delivery_total{outcome}`, `federation_queue_depth`, `federation_inbox_dropped_total{reason}` metrics + journal dashboard row
## 5. Verification