@remit/backend 0.0.40 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
+ import { logger } from "@remit/logger-lambda";
4
+ import {
5
+ metricsContentType,
6
+ onScrape,
7
+ renderMetrics,
8
+ setAccountSyncAges,
9
+ } from "@remit/logger-lambda/metrics";
3
10
  import { isStorageNotFoundError } from "@remit/storage-service";
4
11
  import type { APIGatewayProxyResult } from "aws-lambda";
5
12
  import { env } from "expect-env";
@@ -17,6 +24,7 @@ import { resolveContentPath } from "./content-path.js";
17
24
  import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
18
25
  import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
19
26
  import { checkRelationalStore } from "./relational-health.js";
27
+ import { collectAccountSyncAges } from "./sync-age.js";
20
28
 
21
29
  const app = express();
22
30
 
@@ -137,6 +145,35 @@ app.get("/health", async (_req: Request, res: Response) => {
137
145
  });
138
146
  });
139
147
 
148
+ // The scrape endpoint (standalone-observability D2), on the port this server
149
+ // already serves on and never routed through Caddy — deploy/vps/caddy/routes.caddy
150
+ // proxies /api/*, /content/* and /health, and everything else goes to the static
151
+ // web server, so there is no path from the public origin to this route.
152
+ //
153
+ // The per-account sync age is a database read, so it is collected when a scrape
154
+ // arrives rather than tracked as syncs complete. A read that fails fails the
155
+ // scrape: a signal that could not be evaluated must not render as a healthy
156
+ // number. Only the self-host backends have a store to read here — the
157
+ // AWS-local dev path composes its client from outside this module.
158
+ if (isSelfHostBackend) {
159
+ onScrape(async () => {
160
+ const client = await getClient();
161
+ setAccountSyncAges(await collectAccountSyncAges(client, Date.now()));
162
+ });
163
+ }
164
+
165
+ app.get("/metrics", async (_req: Request, res: Response) => {
166
+ const body = await renderMetrics().catch((error: unknown) => {
167
+ logger.error({ error: String(error) }, "Metrics collection failed");
168
+ return null;
169
+ });
170
+ if (body === null) {
171
+ res.status(500).type("text/plain").send("metrics collection failed\n");
172
+ return;
173
+ }
174
+ res.setHeader("content-type", metricsContentType).send(body);
175
+ });
176
+
140
177
  // Swagger UI exposes the full API schema, so it must never be on the public
141
178
  // surface. On the self-host backends this server is the deployed backend
142
179
  // container; gate the docs to the AWS-local dev path only. The generated
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
4
+ import { collectAccountSyncAges, type SyncAgeSource } from "./sync-age.js";
5
+
6
+ const NOW = 1_700_000_000_000;
7
+
8
+ const account = (overrides: Partial<AccountItem>): AccountItem =>
9
+ ({
10
+ accountId: "acct-1",
11
+ createdAt: NOW - 600_000,
12
+ lastSyncAt: NOW,
13
+ ...overrides,
14
+ }) as AccountItem;
15
+
16
+ const mailbox = (lastMessageSyncAt: number): MailboxItem =>
17
+ ({ mailboxId: `mbx-${lastMessageSyncAt}`, lastMessageSyncAt }) as MailboxItem;
18
+
19
+ const source = (
20
+ accounts: AccountItem[],
21
+ mailboxes: Record<string, MailboxItem[]>,
22
+ ): SyncAgeSource => ({
23
+ account: { listAll: async () => accounts },
24
+ mailbox: {
25
+ listAllByAccount: async (accountId: string) => mailboxes[accountId] ?? [],
26
+ },
27
+ });
28
+
29
+ describe("collectAccountSyncAges", () => {
30
+ it("measures from the newest mailbox message-sync stamp", async () => {
31
+ const ages = await collectAccountSyncAges(
32
+ source([account({})], {
33
+ "acct-1": [mailbox(NOW - 300_000), mailbox(NOW - 60_000)],
34
+ }),
35
+ NOW,
36
+ );
37
+ assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 60 }]);
38
+ });
39
+
40
+ it("ignores account.lastSyncAt, which is stamped before the message fan-out", async () => {
41
+ // lastSyncAt is fresh and every message handler has been failing for an
42
+ // hour. The exported age must report the hour.
43
+ const ages = await collectAccountSyncAges(
44
+ source([account({ lastSyncAt: NOW })], {
45
+ "acct-1": [mailbox(NOW - 3_600_000)],
46
+ }),
47
+ NOW,
48
+ );
49
+ assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 3600 }]);
50
+ });
51
+
52
+ it("treats an unstamped mailbox as never synced", async () => {
53
+ const ages = await collectAccountSyncAges(
54
+ source([account({ createdAt: NOW - 900_000 })], {
55
+ "acct-1": [mailbox(0)],
56
+ }),
57
+ NOW,
58
+ );
59
+ assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 900 }]);
60
+ });
61
+
62
+ it("reports an account with no mailboxes at all rather than omitting it", async () => {
63
+ const ages = await collectAccountSyncAges(
64
+ source([account({ createdAt: NOW - 120_000 })], {}),
65
+ NOW,
66
+ );
67
+ assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 120 }]);
68
+ });
69
+
70
+ it("skips a deleted account", async () => {
71
+ const ages = await collectAccountSyncAges(
72
+ source(
73
+ [
74
+ account({ accountId: "gone", deletedAt: NOW - 1000 }),
75
+ account({ accountId: "live" }),
76
+ ],
77
+ { live: [mailbox(NOW - 30_000)] },
78
+ ),
79
+ NOW,
80
+ );
81
+ assert.deepEqual(ages, [{ accountId: "live", ageSeconds: 30 }]);
82
+ });
83
+
84
+ it("never reports a negative age when a stamp is ahead of the clock", async () => {
85
+ const ages = await collectAccountSyncAges(
86
+ source([account({})], { "acct-1": [mailbox(NOW + 5_000)] }),
87
+ NOW,
88
+ );
89
+ assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 0 }]);
90
+ });
91
+ });
@@ -0,0 +1,55 @@
1
+ import type { IAccountRepository, IMailboxRepository } from "@remit/data-ports";
2
+
3
+ export interface SyncAgeSource {
4
+ readonly account: Pick<IAccountRepository, "listAll">;
5
+ readonly mailbox: Pick<IMailboxRepository, "listAllByAccount">;
6
+ }
7
+
8
+ export interface AccountSyncAge {
9
+ readonly accountId: string;
10
+ readonly ageSeconds: number;
11
+ }
12
+
13
+ /**
14
+ * Seconds since each account last completed a message-sync round
15
+ * (standalone-observability D3).
16
+ *
17
+ * Measured from `mailbox.lastMessageSyncAt`, not `account.lastSyncAt`.
18
+ * `account.lastSyncAt` is stamped after the mailbox *list* sync and before the
19
+ * per-mailbox fan-out that fetches messages, so a deployment whose message
20
+ * handlers all throw keeps it fresh forever while no mail arrives.
21
+ * `lastMessageSyncAt` is stamped at the end of a message-sync round, after the
22
+ * fetch and the writes.
23
+ *
24
+ * The value is the age of the newest such stamp across the account's mailboxes:
25
+ * seconds since this account last completed a round trip that would have found
26
+ * new mail if there were any. An account with no stamped mailbox has never
27
+ * completed one, and reports the age of the account row instead of being
28
+ * omitted — "never synced" is the condition most worth seeing, not a gap in the
29
+ * series.
30
+ *
31
+ * Labelled by account id, never by address: a scraped label travels wherever
32
+ * the scrape goes.
33
+ */
34
+ export const collectAccountSyncAges = async (
35
+ source: SyncAgeSource,
36
+ now: number,
37
+ ): Promise<AccountSyncAge[]> => {
38
+ const accounts = await source.account.listAll();
39
+ const ages: AccountSyncAge[] = [];
40
+ for (const account of accounts) {
41
+ if (account.deletedAt) continue;
42
+ const mailboxes = await source.mailbox.listAllByAccount(account.accountId);
43
+ const stamps = mailboxes
44
+ .map((mailbox) => mailbox.lastMessageSyncAt)
45
+ .filter(
46
+ (stamp): stamp is number => typeof stamp === "number" && stamp > 0,
47
+ );
48
+ const newest = stamps.length > 0 ? Math.max(...stamps) : account.createdAt;
49
+ ages.push({
50
+ accountId: account.accountId,
51
+ ageSeconds: Math.max(0, Math.round((now - newest) / 1000)),
52
+ });
53
+ }
54
+ return ages;
55
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.40",
3
+ "version": "0.0.41",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",