@remit/queue-sidecar 0.0.7 → 0.0.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/queue-sidecar",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -20,8 +20,10 @@
20
20
  "test": "npm run test:typecheck && npm run test:run"
21
21
  },
22
22
  "dependencies": {
23
+ "@remit/logger-lambda": "*",
23
24
  "better-sqlite3": "^12.11.1",
24
- "@types/better-sqlite3": "^7.6.13"
25
+ "@types/better-sqlite3": "^7.6.13",
26
+ "prom-client": "^15.1.3"
25
27
  },
26
28
  "devDependencies": {
27
29
  "@aws-sdk/client-sqs": "*",
@@ -0,0 +1,197 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import type { Server } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { join } from "node:path";
6
+ import { after, before, beforeEach, describe, it } from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { resetMetrics } from "@remit/logger-lambda/metrics";
9
+ import { collectQueueDepths, queueRoles } from "./metrics.js";
10
+ import { bootstrapQueues, parseQueuesConfig } from "./queues-config.js";
11
+ import { createSidecarServer } from "./server.js";
12
+ import { QueueStore } from "./store.js";
13
+
14
+ const tmpRoot = join(
15
+ fileURLToPath(new URL(".", import.meta.url)),
16
+ "..",
17
+ ".tmp",
18
+ "metrics",
19
+ );
20
+
21
+ const config = parseQueuesConfig({
22
+ queues: [
23
+ {
24
+ name: "work",
25
+ visibilityTimeoutSeconds: 30,
26
+ deadLetter: { name: "work-dlq", maxReceiveCount: 3 },
27
+ },
28
+ { name: "work-dlq", visibilityTimeoutSeconds: 30 },
29
+ ],
30
+ });
31
+
32
+ const sample = (text: string, labels: string[]): number => {
33
+ const line = text
34
+ .split("\n")
35
+ .find(
36
+ (candidate) =>
37
+ candidate.startsWith("remit_queue_messages{") &&
38
+ labels.every((label) => candidate.includes(label)),
39
+ );
40
+ assert.ok(line, `expected a sample for ${labels.join("")} in:\n${text}`);
41
+ return Number(line.slice(line.lastIndexOf(" ") + 1));
42
+ };
43
+
44
+ describe("queue depth roles", () => {
45
+ it("names a redrive target a dead-letter queue and everything else work", () => {
46
+ const roles = queueRoles([
47
+ {
48
+ name: "work",
49
+ fifo: false,
50
+ visibilityTimeoutSeconds: 30,
51
+ deadLetterTargetName: "work-dlq",
52
+ maxReceiveCount: 3,
53
+ contentBasedDeduplication: false,
54
+ },
55
+ {
56
+ name: "work-dlq",
57
+ fifo: false,
58
+ visibilityTimeoutSeconds: 30,
59
+ deadLetterTargetName: null,
60
+ maxReceiveCount: null,
61
+ contentBasedDeduplication: false,
62
+ },
63
+ ]);
64
+ assert.equal(roles.get("work"), "work");
65
+ assert.equal(roles.get("work-dlq"), "dead_letter");
66
+ });
67
+ });
68
+
69
+ describe("the sidecar /metrics endpoint", () => {
70
+ let dir: string;
71
+ let store: QueueStore;
72
+ let server: Server;
73
+ let port: number;
74
+
75
+ const get = async (
76
+ path: string,
77
+ ): Promise<{ status: number; contentType: string; body: string }> => {
78
+ const response = await fetch(`http://127.0.0.1:${port}${path}`);
79
+ return {
80
+ status: response.status,
81
+ contentType: response.headers.get("content-type") ?? "",
82
+ body: await response.text(),
83
+ };
84
+ };
85
+
86
+ before(async () => {
87
+ mkdirSync(tmpRoot, { recursive: true });
88
+ dir = mkdtempSync(join(tmpRoot, "run-"));
89
+ store = new QueueStore(join(dir, "queue.db"));
90
+ bootstrapQueues(store, config);
91
+ server = createSidecarServer({ store });
92
+ port = await new Promise<number>((resolve) => {
93
+ server.listen(0, "127.0.0.1", () =>
94
+ resolve((server.address() as AddressInfo).port),
95
+ );
96
+ });
97
+ });
98
+
99
+ after(async () => {
100
+ await new Promise<void>((resolve) => server.close(() => resolve()));
101
+ store.close();
102
+ rmSync(dir, { recursive: true, force: true });
103
+ });
104
+
105
+ beforeEach(() => resetMetrics());
106
+
107
+ it("reports every queue at zero on an idle stack", async () => {
108
+ const response = await get("/metrics");
109
+ assert.equal(response.status, 200);
110
+ assert.match(response.contentType, /^text\/plain/);
111
+ assert.match(response.body, /^# TYPE remit_queue_messages gauge$/m);
112
+ assert.equal(sample(response.body, ['queue="work"', 'role="work"']), 0);
113
+ assert.equal(
114
+ sample(response.body, ['queue="work-dlq"', 'role="dead_letter"']),
115
+ 0,
116
+ );
117
+ });
118
+
119
+ it("reports work depth and dead-letter depth separately", async () => {
120
+ store.sendMessage({ queueName: "work", body: "a" });
121
+ store.sendMessage({ queueName: "work", body: "b" });
122
+ store.sendMessage({ queueName: "work-dlq", body: "poison" });
123
+ const { body } = await get("/metrics");
124
+ assert.equal(sample(body, ['queue="work"', 'role="work"']), 2);
125
+ assert.equal(sample(body, ['queue="work-dlq"']), 1);
126
+ });
127
+
128
+ it("re-reads depth on every scrape", async () => {
129
+ store.purgeQueue("work");
130
+ store.purgeQueue("work-dlq");
131
+ const { body } = await get("/metrics");
132
+ assert.equal(sample(body, ['queue="work"', 'role="work"']), 0);
133
+ });
134
+
135
+ it("keeps the SQS protocol and the health route unchanged", async () => {
136
+ assert.equal((await get("/health")).status, 200);
137
+ const post = await fetch(`http://127.0.0.1:${port}/metrics`, {
138
+ method: "POST",
139
+ body: "Action=ListQueues",
140
+ });
141
+ assert.equal(post.status, 400);
142
+ });
143
+
144
+ it("counts only visible messages as depth", () => {
145
+ store.sendMessage({ queueName: "work", body: "c" });
146
+ store.receiveMessages({ queueName: "work", maxMessages: 1 });
147
+ collectQueueDepths(store);
148
+ assert.equal(
149
+ store.getQueueAttributes("work").approximateNumberOfMessages,
150
+ 0,
151
+ );
152
+ });
153
+ });
154
+
155
+ describe("a store that cannot answer for any queue", () => {
156
+ // A deployment always holds the queues queues.json declares. An empty table
157
+ // means the sidecar is reading the wrong file, and rendering that as a series
158
+ // with no samples is the same blindness this endpoint exists to remove: a
159
+ // dead-letter alert written on `> 0` would never fire, behind a 200.
160
+ it("throws rather than reporting no queues", () => {
161
+ assert.throws(
162
+ () =>
163
+ collectQueueDepths({
164
+ listQueues: () => [],
165
+ getQueueAttributes: () => {
166
+ throw new Error("never reached");
167
+ },
168
+ }),
169
+ /holds no queues/,
170
+ );
171
+ });
172
+
173
+ it("fails the scrape with a non-200 and no depth series", async () => {
174
+ mkdirSync(tmpRoot, { recursive: true });
175
+ const dir = mkdtempSync(join(tmpRoot, "empty-"));
176
+ const store = new QueueStore(join(dir, "queue.db"));
177
+ const server = createSidecarServer({ store });
178
+ const port = await new Promise<number>((resolve) => {
179
+ server.listen(0, "127.0.0.1", () =>
180
+ resolve((server.address() as AddressInfo).port),
181
+ );
182
+ });
183
+
184
+ const response = await fetch(`http://127.0.0.1:${port}/metrics`);
185
+ const body = await response.text();
186
+ assert.equal(response.status, 500);
187
+ assert.match(body, /holds no queues/);
188
+ assert.ok(
189
+ !body.includes("remit_queue_messages"),
190
+ "a failed scrape must render no depth series at all",
191
+ );
192
+
193
+ await new Promise<void>((resolve) => server.close(() => resolve()));
194
+ store.close();
195
+ rmSync(dir, { recursive: true, force: true });
196
+ });
197
+ });
package/src/metrics.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { registry } from "@remit/logger-lambda/metrics";
2
+ import { Gauge } from "prom-client";
3
+ import type { QueueRecord, QueueStore } from "./store.js";
4
+
5
+ /**
6
+ * Whether a queue is where work is sent or where it is quarantined. A
7
+ * dead-letter queue is not a different kind of queue — it is a queue some other
8
+ * queue redrives into — so the depth series is one metric and the role is a
9
+ * label, derived from the redrive policies the store already holds.
10
+ */
11
+ export type QueueRole = "work" | "dead_letter";
12
+
13
+ const queueMessages = new Gauge({
14
+ name: "remit_queue_messages",
15
+ help: "Messages currently on a queue, by queue and role.",
16
+ labelNames: ["queue", "role"],
17
+ registers: [registry],
18
+ });
19
+
20
+ export type QueueDepthSource = Pick<
21
+ QueueStore,
22
+ "listQueues" | "getQueueAttributes"
23
+ >;
24
+
25
+ export const queueRoles = (
26
+ queues: readonly QueueRecord[],
27
+ ): Map<string, QueueRole> => {
28
+ const deadLetterTargets = new Set(
29
+ queues.flatMap((queue) =>
30
+ queue.deadLetterTargetName === null ? [] : [queue.deadLetterTargetName],
31
+ ),
32
+ );
33
+ return new Map(
34
+ queues.map((queue) => [
35
+ queue.name,
36
+ deadLetterTargets.has(queue.name) ? "dead_letter" : "work",
37
+ ]),
38
+ );
39
+ };
40
+
41
+ /**
42
+ * Re-read every queue's depth. The whole series is replaced rather than added
43
+ * to, so a purged or deleted queue stops reporting its last known depth instead
44
+ * of pinning it forever.
45
+ *
46
+ * A store with no queues in it throws. A deployment always has the queues
47
+ * `queues.json` declares, so an empty table means the sidecar is pointed at the
48
+ * wrong `QUEUE_SIDECAR_DB`, or at a file `bootstrapQueues` never ran against.
49
+ * Rendering that as a series with no samples is the same failure this endpoint
50
+ * exists to remove: `remit_queue_messages{role="dead_letter"} > 0` never fires
51
+ * and the operator is as blind as before, with a 200 saying otherwise.
52
+ */
53
+ export const collectQueueDepths = (store: QueueDepthSource): void => {
54
+ const queues = store.listQueues();
55
+ if (queues.length === 0) {
56
+ throw new Error(
57
+ "queue store holds no queues — the sidecar is reading the wrong database, or none was bootstrapped",
58
+ );
59
+ }
60
+ const roles = queueRoles(queues);
61
+ queueMessages.reset();
62
+ for (const queue of queues) {
63
+ const attributes = store.getQueueAttributes(queue.name);
64
+ queueMessages.set(
65
+ { queue: queue.name, role: roles.get(queue.name) ?? "work" },
66
+ attributes.approximateNumberOfMessages,
67
+ );
68
+ }
69
+ };
package/src/server.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import { createServer, type Server } from "node:http";
4
+ import {
5
+ metricsContentType,
6
+ renderMetrics,
7
+ } from "@remit/logger-lambda/metrics";
8
+ import { collectQueueDepths } from "./metrics.js";
4
9
  import {
5
10
  type BatchErrorEntry,
6
11
  type BatchSuccessEntry,
@@ -384,6 +389,26 @@ export const createSidecarServer = (options: CreateSidecarOptions): Server => {
384
389
  res.end("ok");
385
390
  return;
386
391
  }
392
+ // Queue and dead-letter depth (standalone-observability D2/D3), on the port
393
+ // the SQS protocol already answers on. The depths are read here rather than
394
+ // counted as messages move: the store is the only thing that knows them,
395
+ // and each is two counts over a `(queue_name, id)` index range with a
396
+ // `visible_at` filter per row — a table that is empty in steady state.
397
+ if (req.method === "GET" && req.url === "/metrics") {
398
+ Promise.resolve()
399
+ .then(() => collectQueueDepths(store))
400
+ .then(renderMetrics)
401
+ .then((body) => {
402
+ res.writeHead(200, { "content-type": metricsContentType });
403
+ res.end(body);
404
+ })
405
+ .catch((error: unknown) => {
406
+ log.error({ error: String(error) }, "sidecar: metrics failed");
407
+ res.writeHead(500, { "content-type": "text/plain" });
408
+ res.end(`metrics collection failed: ${String(error)}\n`);
409
+ });
410
+ return;
411
+ }
387
412
  if (req.method !== "POST") {
388
413
  respondError(res, new InvalidRequestError("only POST is supported"));
389
414
  return;