Skip to content

Commit 3cfa40f

Browse files
authored
feat: add ops HTTP server with /healthz, /readyz, and Prometheus /metrics (#24)
* feat: add ops HTTP server with /healthz, /readyz, and Prometheus /metrics Kubernetes has been operating this pod blind — no liveness or readiness signal means a stuck consumer looks healthy from the cluster's perspective. Observability beyond the structured log stream was also missing, so dashboards and alerts had to be log-derived. - New minimal HTTP server (node:http, no framework) on HTTP_PORT (default 8080). Starts before RabbitMQ so /healthz is answerable during init and stops last during shutdown. - /healthz: always 200 while the process is alive. - /readyz: 200 only when RabbitMQ is connected AND not shutting down. Flips to 503 at the start of graceful shutdown so load balancers drain the pod before migrations stop. - /metrics: Prometheus text exposition via prom-client. Includes default Node runtime metrics plus: - nextcloud_migration_active (gauge) - nextcloud_migration_started_total (counter) - nextcloud_migration_finished_total{outcome} - nextcloud_migration_files_total{outcome} - nextcloud_migration_file_transfer_duration_seconds (histogram) - nextcloud_migration_cloudery_token_total{outcome} - nextcloud_migration_rabbitmq_connected (gauge) - Metrics live on a shared registry in src/runtime/metrics.ts so call sites (migration.ts, migration-runner.ts, cloudery-client.ts, index.ts) increment without threading a registry through every factory. - HTTP_PORT config + validation, docs (configuration, operations, development) updated. * refactor: lazy default metrics + pull-based active-migrations gauge - collectDefaultMetrics() moved behind an enableDefaultMetrics() function called once from main(). Test runs that import metrics.ts no longer kick off the default runtime timers at module load. - active-migrations is now a pull-model gauge: the runner holds the count, metrics.ts exposes bindActiveMigrationsSource() which main() wires once. The runner no longer imports the metrics module at all, so the coupling runs one direction only. - Small clarifying note on the http-server test's ephemeral-port helper.
1 parent c14a2ee commit 3cfa40f

13 files changed

Lines changed: 446 additions & 13 deletions

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ Lower values give the Settings UI more frequent progress updates at the cost of
6464
FLUSH_INTERVAL="50"
6565
```
6666

67+
### `HTTP_PORT`
68+
69+
TCP port the ops HTTP server binds on. Defaults to `8080`. The server exposes `/healthz`, `/readyz`, and `/metrics` — see [Operations](operations.md#health-and-metrics) for what each returns.
70+
71+
```
72+
HTTP_PORT="8080"
73+
```
74+
6775
### `MAX_CONCURRENT_MIGRATIONS`
6876

6977
Hard cap on the number of migrations the consumer will run at the same time. Defaults to `10`, matching the RabbitMQ prefetch.

docs/development.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ src/
4747
runtime/ Process wiring. How domain + clients get started and fed.
4848
consumer.ts Message handler — validation, idempotency, quota check, early ACK
4949
migration-runner.ts Concurrency cap + in-flight tracking for graceful shutdown
50+
http-server.ts Ops HTTP server — /healthz, /readyz, /metrics
51+
metrics.ts Prometheus registry + metric definitions
5052
config.ts Environment variable parsing + Config type
5153
5254
test/

docs/operations.md

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,41 @@
22

33
Runbook for operating the service in production. Covers how to tell when something is wrong, what the service does automatically, and when a human needs to step in.
44

5-
## Health signal
5+
## Health and metrics
66

7-
The service has no HTTP health endpoint. What you can observe externally:
7+
The service exposes a small HTTP server on `HTTP_PORT` (default `8080`) with three endpoints:
8+
9+
| Path | Purpose |
10+
|---|---|
11+
| `/healthz` | Liveness probe. Always returns 200 while the process is alive. Deliberately cheap — a stuck event loop can still return 200, which is the point of separating it from readiness. |
12+
| `/readyz` | Readiness probe. Returns 200 only when RabbitMQ is connected and the process is not shutting down. Flips to 503 as soon as SIGTERM is received so load balancers and `PodDisruptionBudget` logic stop routing to the pod before the drain begins. |
13+
| `/metrics` | Prometheus text exposition. Scrape interval of 15–30 s is plenty; the metrics below are counters and gauges, not high-cardinality. |
14+
15+
In Kubernetes, point `livenessProbe` at `/healthz` and `readinessProbe` at `/readyz`. The default Helm chart values do this for you.
16+
17+
### Metrics worth alerting on
18+
19+
| Metric | Type | What it tells you |
20+
|---|---|---|
21+
| `nextcloud_migration_active` | gauge | How many migrations are in-flight right now. Sustained at the concurrency cap means you're queue-bound. |
22+
| `nextcloud_migration_started_total` | counter | Migration arrival rate. Alert on a drop to zero while the RabbitMQ queue has items — indicates the consumer has stopped picking work up. |
23+
| `nextcloud_migration_finished_total{outcome}` | counter | `outcome="completed"` vs `outcome="failed"`. Alert on a sustained failure ratio above your tolerance (e.g. > 10%). |
24+
| `nextcloud_migration_files_total{outcome}` | counter | Per-file outcomes (`transferred`, `skipped`, `failed`). `skipped` is normal during resumes; `failed` is not. |
25+
| `nextcloud_migration_file_transfer_duration_seconds` | histogram | Per-file transfer latency. A shift in the upper buckets is an early signal of Stack or Nextcloud slowdown. |
26+
| `nextcloud_migration_cloudery_token_total{outcome}` | counter | `outcome="success"` vs `"failed"`. A spike in failed tokens points at Cloudery, not at this service. |
27+
| `nextcloud_migration_rabbitmq_connected` | gauge | `1` when connected, `0` otherwise. Complements the `/readyz` probe for dashboards. |
28+
29+
Default Node.js runtime metrics (event loop lag, heap, GC) are included automatically by `prom-client`.
30+
31+
## Log-based signals
32+
33+
Even with metrics, the event log remains the source of truth for per-migration diagnosis:
834

935
- **Process uptime.** Log lines with `event: service.starting`, `service.shutting_down`, `service.stopped` delimit lifetimes.
10-
- **RabbitMQ connection.** An `event: rabbitmq.connected` on startup means the broker is reachable. Silent thereafter; connection drops surface as errors from the underlying library.
11-
- **Consumption rate.** Count `event: consumer.message_received` over time. A healthy instance emits one per incoming migration request.
36+
- **RabbitMQ connection.** An `event: rabbitmq.connected` on startup means the broker is reachable.
37+
- **Consumption rate.** Count `event: consumer.message_received` over time.
1238

13-
If the process is alive and RabbitMQ is drained but the queue keeps growing, look at the concurrency cap next.
39+
If the process is alive, `/readyz` is green, but the queue keeps growing, look at the concurrency cap next.
1440

1541
## Events worth alerting on
1642

@@ -74,8 +100,10 @@ See [Configuration](configuration.md) for the full list.
74100

75101
On `SIGTERM` or `SIGINT`:
76102

77-
1. RabbitMQ subscription closes immediately — no new messages are accepted.
78-
2. The process waits up to 60 seconds for in-flight migrations to finish naturally.
79-
3. If the deadline passes, the process exits anyway. Anything still running becomes a stale-running zombie and is reclaimed by the next consumer.
103+
1. `/readyz` flips to 503 immediately so load balancers stop routing to the pod.
104+
2. RabbitMQ subscription closes — no new messages are accepted.
105+
3. The process waits up to 60 seconds for in-flight migrations to finish naturally.
106+
4. The ops HTTP server closes.
107+
5. If the drain deadline passed, the process exits anyway. Anything still running becomes a stale-running zombie and is reclaimed by the next consumer.
80108

81109
This matters for rolling deployments: if you expect migrations to routinely run longer than 60 seconds (most do), you'll see `service.stopped` with `drained: false` on every rollout. That's not a regression — it's the heartbeat recovery doing its job.

package-lock.json

Lines changed: 39 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"dependencies": {
1515
"@linagora/rabbitmq-client": "^0.1.2",
1616
"cozy-stack-client": "^60.23.0",
17-
"pino": "^9.6.0"
17+
"pino": "^9.6.0",
18+
"prom-client": "^15.1.3"
1819
},
1920
"devDependencies": {
2021
"@types/node": "^20.17.0",

src/clients/cloudery-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Logger } from 'pino'
22
import { MIGRATION_TOKEN_SCOPE } from '../domain/doctypes.js'
3+
import { clouderyTokenRequests } from '../runtime/metrics.js'
34

45
export interface ClouderyClient {
56
/** Returns a Stack token for the given instance, using the cache when fresh. */
@@ -127,7 +128,9 @@ export function createClouderyClient(
127128
let lastError: unknown
128129
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
129130
try {
130-
return await fetchOnce(workplaceFqdn)
131+
const token = await fetchOnce(workplaceFqdn)
132+
clouderyTokenRequests.inc({ outcome: 'success' })
133+
return token
131134
} catch (error) {
132135
lastError = error
133136
if (!isRetryable(error) || attempt === MAX_ATTEMPTS - 1) break
@@ -143,6 +146,7 @@ export function createClouderyClient(
143146
await new Promise<void>((resolve) => setTimeout(resolve, delay))
144147
}
145148
}
149+
clouderyTokenRequests.inc({ outcome: 'failed' })
146150
logger.error({
147151
event: 'cloudery.token_failed',
148152
instance: workplaceFqdn,

src/domain/migration.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import type { Logger } from 'pino'
22
import type { CozyDir, StackClient } from '../clients/stack-client.js'
33
import type { MigrationCommand } from './types.js'
44
import { getErrorMessage } from './errors.js'
5+
import {
6+
fileTransferDuration,
7+
filesProcessed,
8+
migrationsFinished,
9+
migrationsStarted,
10+
} from '../runtime/metrics.js'
511
import {
612
setRunning,
713
flushProgress,
@@ -150,6 +156,9 @@ async function handleFileEntry(
150156
try {
151157
const fileStart = Date.now()
152158
const file = await ctx.stackClient.transferFile(accountId, entry.path, cozyDirId)
159+
const durationMs = Date.now() - fileStart
160+
fileTransferDuration.observe(durationMs / 1000)
161+
filesProcessed.inc({ outcome: 'transferred' })
153162
ctx.transferred.bytes += file.size
154163
ctx.transferred.files += 1
155164
ctx.pending.bytesImported += file.size
@@ -160,7 +169,7 @@ async function handleFileEntry(
160169
event: 'migration.file_transferred',
161170
nc_path: entry.path,
162171
size: file.size,
163-
duration_ms: Date.now() - fileStart,
172+
duration_ms: durationMs,
164173
transferred_bytes: ctx.transferred.bytes,
165174
transferred_files: ctx.transferred.files,
166175
discovered_bytes: ctx.discovered.bytesTotal,
@@ -175,6 +184,7 @@ async function handleFileEntry(
175184
}
176185
} catch (error) {
177186
if (isConflictError(error)) {
187+
filesProcessed.inc({ outcome: 'skipped' })
178188
ctx.totalSkipped += 1
179189
ctx.logger.info({
180190
event: 'migration.file_skipped',
@@ -187,6 +197,7 @@ async function handleFileEntry(
187197
ctx.pending.skipped.push({ path: entry.path, reason: 'already exists', size: entry.size })
188198
return
189199
}
200+
filesProcessed.inc({ outcome: 'failed' })
190201
ctx.totalErrors += 1
191202
const message = getErrorMessage(error)
192203
ctx.logger.error({
@@ -253,6 +264,7 @@ export async function runMigration(
253264
startedAt: Date.now(),
254265
}
255266

267+
migrationsStarted.inc()
256268
try {
257269
migrationLogger.info({ event: 'migration.started' }, 'Migration started')
258270

@@ -267,6 +279,7 @@ export async function runMigration(
267279
ctx.discovered.filesTotal,
268280
)
269281

282+
migrationsFinished.inc({ outcome: 'completed' })
270283
migrationLogger.info({
271284
event: 'migration.completed',
272285
duration_ms: Date.now() - ctx.startedAt,
@@ -279,6 +292,7 @@ export async function runMigration(
279292
}, 'Migration completed')
280293
} catch (error) {
281294
const message = getErrorMessage(error)
295+
migrationsFinished.inc({ outcome: 'failed' })
282296
migrationLogger.error({
283297
event: 'migration.failed',
284298
duration_ms: Date.now() - ctx.startedAt,

src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { loadConfig } from './runtime/config.js'
44
import { createClouderyClient } from './clients/cloudery-client.js'
55
import { handleMigrationMessage } from './runtime/consumer.js'
66
import { createMigrationRunner } from './runtime/migration-runner.js'
7+
import { createOpsServer } from './runtime/http-server.js'
8+
import {
9+
bindActiveMigrationsSource,
10+
enableDefaultMetrics,
11+
rabbitmqConnected,
12+
} from './runtime/metrics.js'
713
import { parseMigrationCommand } from './domain/types.js'
814

915
const EXCHANGE = 'migration'
@@ -24,8 +30,10 @@ async function main(): Promise<void> {
2430

2531
logger.info({ event: 'service.starting' }, 'Starting Nextcloud migration service')
2632

33+
enableDefaultMetrics()
2734
const clouderyClient = createClouderyClient(config.clouderyUrl, config.clouderyToken, logger)
2835
const migrationRunner = createMigrationRunner(config.maxConcurrentMigrations, logger)
36+
bindActiveMigrationsSource(() => migrationRunner.active)
2937

3038
const rabbitClient = new RabbitMQClient({
3139
url: config.rabbitmqUrl,
@@ -35,7 +43,22 @@ async function main(): Promise<void> {
3543
logger,
3644
})
3745

46+
let shuttingDown = false
47+
const opsServer = createOpsServer(
48+
config.httpPort,
49+
{
50+
isRabbitMQConnected: () => rabbitClient.isConnected(),
51+
isShuttingDown: () => shuttingDown,
52+
},
53+
logger,
54+
)
55+
// Start the ops server BEFORE RabbitMQ so /healthz is answerable
56+
// while init runs — Kubernetes sees a live pod even if broker
57+
// connection takes a moment.
58+
await opsServer.start()
59+
3860
await rabbitClient.init()
61+
rabbitmqConnected.set(1)
3962
logger.info({ event: 'rabbitmq.connected' }, 'Connected to RabbitMQ')
4063

4164
await rabbitClient.subscribe(
@@ -66,7 +89,6 @@ async function main(): Promise<void> {
6689
routing_key: ROUTING_KEY,
6790
}, 'Subscribed to migration queue')
6891

69-
let shuttingDown = false
7092
const shutdown = async (signal: string) => {
7193
if (shuttingDown) return
7294
shuttingDown = true
@@ -76,7 +98,9 @@ async function main(): Promise<void> {
7698
active_migrations: migrationRunner.active,
7799
}, 'Shutting down')
78100
await rabbitClient.close()
101+
rabbitmqConnected.set(0)
79102
const drained = await migrationRunner.drain(SHUTDOWN_DRAIN_MS)
103+
await opsServer.stop()
80104
logger.info({
81105
event: 'service.stopped',
82106
drained,

src/runtime/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface Config {
1111
/** Maximum number of migrations allowed to run concurrently. Defaults
1212
* to 10 to match the RabbitMQ prefetch. */
1313
maxConcurrentMigrations: number
14+
/** TCP port the ops HTTP server (probes + /metrics) binds on. */
15+
httpPort: number
1416
}
1517

1618
function requireEnv(name: string): string {
@@ -39,6 +41,12 @@ export function loadConfig(): Config {
3941
`MAX_CONCURRENT_MIGRATIONS must be a positive integer, got: ${process.env.MAX_CONCURRENT_MIGRATIONS}`,
4042
)
4143
}
44+
const httpPort = parseInt(process.env.HTTP_PORT ?? '8080', 10)
45+
if (!Number.isFinite(httpPort) || httpPort < 1 || httpPort > 65535) {
46+
throw new Error(
47+
`HTTP_PORT must be a TCP port (1-65535), got: ${process.env.HTTP_PORT}`,
48+
)
49+
}
4250
return {
4351
rabbitmqUrl: requireEnv('RABBITMQ_URL'),
4452
clouderyUrl: requireEnv('CLOUDERY_URL'),
@@ -47,5 +55,6 @@ export function loadConfig(): Config {
4755
flushInterval: parseInt(process.env.FLUSH_INTERVAL ?? '25', 10),
4856
stackUrlScheme: rawScheme,
4957
maxConcurrentMigrations: maxConcurrent,
58+
httpPort,
5059
}
5160
}

0 commit comments

Comments
 (0)