OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / e58c21f

0.10.16: metrics() for the gateway panel — users, queue, inbound, registrations
Per user: followers, delivered-set size (from the publisher's own state file), held. Queue: pending, dead, oldest pending, and up to 10 failing entries with the inbox HOST, attempts and last error (no activity bodies). Inbound: count and last received. Nothing touches the network. Test first (red: b.metrics is not a function); suite 367/367. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e58c21fc3298aa8cd70f21bd6b8892b3e6d34f89
by plankatron <fleeky@gmail.com> · 2026-10-13T22:34:42.000Z

Changed files (4) +70 -6

lib/bridge.js +53 -3
……@@ -107,8 +107,10 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
107107 // ⚠ Assigned below, once PerUser exists. A null resolver means a queued entry with a signerId is
108108 // HELD rather than sent under the wrong identity — the safe direction.
109109 let signerByKeyId = null
110 // Kept, so metrics() can read pending and dead entries; the deliverer exposes only counts.
111 const deliveryQueue = new DeliveryQueue(join(dataDir, 'queue.json'))
110112 const outbound = createQueuedDeliverer({
111 queue: new DeliveryQueue(join(dataDir, 'queue.json')),
113 queue: deliveryQueue,
112114 deliver: sendNow,
113115 resolveSigner: (keyId) => (signerByKeyId ? signerByKeyId(keyId) : null),
114116 log
……@@ -324,9 +326,12 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
324326 ? config.users
325327 : (registrations ? [{ username: config.username, primary: true, favelaSocket: config.favelaSocket || null }] : null)
326328
329 // Hoisted out of the block below so metrics() can read them; null when there is no user registry.
330 let users = null
331 let perUser = null
327332 if (userEntries) {
328 const users = new Users(userEntries, config.host)
329 const perUser = new PerUser({
333 users = new Users(userEntries, config.host)
334 perUser = new PerUser({
330335 users,
331336 dataDir,
332337 // ⛔ The overlay's Files, so a held user's avatar resolves from THEIR bytes rather than being
……@@ -632,6 +637,50 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
632637 }
633638 }
634639
640 /**
641 * What the gateway's panel and CLI show for this role — read from what the bridge already keeps on
642 * disk and in memory. Nothing here talks to the network.
643 *
644 * `delivered` is the publisher's delivered-set size (rkeys sent at least once), read from the same
645 * state file the publisher writes. `failing` lists pending entries that have failed at least once,
646 * newest error first, with the inbox HOST only — an operator needs to see which server is refusing,
647 * not the activity body.
648 */
649 const metrics = async () => {
650 const deliveredFor = (u) => {
651 const file = u.primary ? 'publisher.json' : 'publisher-' + u.username + '.json'
652 try {
653 const d = JSON.parse(fs.readFileSync(join(dataDir, file), 'utf8')).delivered
654 return Array.isArray(d) ? d.length : Object.keys(d || {}).length
655 } catch { return 0 }
656 }
657 const list = users ? users.all() : [{ username: config.username, primary: true }]
658 const userRows = []
659 for (const u of list) {
660 const store = u.primary || !perUser ? followers : perUser.followers(u)
661 userRows.push({ username: u.username, primary: !!u.primary, held: !!u.peer, followers: await store.count(), delivered: deliveredFor(u) })
662 }
663 const pending = await deliveryQueue.list()
664 const dead = await deliveryQueue.dead()
665 const hostOf = (inbox) => { try { return new URL(inbox).host } catch { return String(inbox || '') } }
666 const failing = pending.filter((e) => e.attempts > 0)
667 .sort((a, b) => (b.nextAt || 0) - (a.nextAt || 0))
668 .slice(0, 10)
669 .map((e) => ({ host: hostOf(e.inbox), attempts: e.attempts, lastError: e.lastError, nextAt: e.nextAt }))
670 const newest = await inbound.list({ limit: 1 })
671 return {
672 users: userRows,
673 queue: {
674 pending: pending.length,
675 dead: dead.length,
676 oldestPendingAt: pending.length ? Math.min(...pending.map((e) => e.createdAt || Date.now())) : null,
677 failing
678 },
679 inbound: { count: await inbound.count(), lastReceivedAt: newest.length ? (newest[0].receivedAt || null) : null },
680 registrations: registrations ? (await registrations.list()).length : 0
681 }
682 }
683
635684 const closeServer = (s) => new Promise((resolve) => s.close(() => resolve()))
636685
637686 return {
……@@ -639,6 +688,7 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
639688 adminPort,
640689 bridgeKey: open.reg ? open.reg.key : null,
641690 ...callbacks,
691 metrics,
642692 handlers: bridgeHandlers({ name: 'ap-bridge', version, ...callbacks }),
643693 // The stop sequence. The delivery drain first — never the queue itself: anything still owed is on
644694 // disk and is picked up by the next boot, which is the difference that module was built to make.
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.15",
3 "version": "0.10.16",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "ap-bridge",
9 "version": "0.10.15",
9 "version": "0.10.16",
1010 "license": "MIT",
1111 "dependencies": {
1212 "atproto-did-peer": "file:../atproto-did-peer",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.15",
3 "version": "0.10.16",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/start-bridge.test.js +14 -0
……@@ -94,3 +94,17 @@ function socketAnswers (p) {
9494 }).catch(() => resolve(false))
9595 })
9696}
97
98// ── metrics: what the gateway's panel and CLI show for the fediverse role ─────────────────────────────
99test('★ metrics: users, queue, inbound and registrations, read from what the bridge already keeps', async (t) => {
100 const b = await startApBridge(configFor(dataDir()), { bootstrap: [], log: () => {} })
101 t.teardown(() => stop(b))
102 const m = await b.metrics()
103 t.alike(m.users.map((u) => u.username), ['p2p'], 'the primary, by name')
104 t.is(m.users[0].primary, true)
105 t.is(m.users[0].followers, 0, 'followers counted')
106 t.is(m.users[0].delivered, 0, 'delivered counted from the publisher state')
107 t.alike(m.queue, { pending: 0, dead: 0, oldestPendingAt: null, failing: [] }, 'an empty delivery queue')
108 t.alike(m.inbound, { count: 0, lastReceivedAt: null })
109 t.is(m.registrations, 0)
110})