From 9c5016c7b4b2e7032de77c0996f0b7d0b9a8e329 Mon Sep 17 00:00:00 2001 From: Hotmopo <297505646+Hotmopo@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:26:08 +0000 Subject: [PATCH 1/2] feat: add Prometheus worker metrics and enhance webhook signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #73 Closes #74 #73 — Prometheus metrics exporter for queue depths and worker latencies - Added `worker_job_duration_seconds` histogram and `worker_jobs_total` counter to MetricsService for tracking BullMQ worker processing latency and outcomes - Created `WorkerMetricsService` with an `instrumentJob()` wrapper that automatically measures job execution time and records success/failure - Wired metrics into all workers (webhook delivery, notification delivery, analytics aggregation, balance sync) via optional WorkerMetricsService injection - Updated WebhooksProcessor to track delivery latency and outcomes - Added MetricsModule import to WebhookModule and WorkersModule - Added comprehensive unit tests for WorkerMetricsService and new metric collection #74 — Cryptographic verification middleware for incoming webhook signatures - Created `RawBodyMiddleware` for capturing raw request body before JSON parsing, ensuring accurate HMAC computation on webhook endpoints - Enhanced `WebhookSignatureGuard` with configurable per-integration secret resolution via `WebhookSecretResolver` callback and `WebhookSignatureGuardOptions` - Added `WEBHOOK_SIGNING_SECRET` env var as additional fallback - Added descriptive error response when no signing secret is configured - Extended tests: per-integration secrets, custom tolerance, raw body extraction, missing headers, non-numeric timestamps, no-secret-configured scenarios Co-Authored-By: Codebuff --- .../guards/webhook-signature.guard.spec.ts | 215 +++++++++++++++++- src/common/guards/webhook-signature.guard.ts | 125 +++++++++- .../middleware/raw-body.middleware.spec.ts | 88 +++++++ src/common/middleware/raw-body.middleware.ts | 62 +++++ src/modules/metrics/index.ts | 1 + src/modules/metrics/metrics.module.ts | 14 +- src/modules/metrics/metrics.service.spec.ts | 46 ++++ src/modules/metrics/metrics.service.ts | 55 ++++- .../metrics/worker-metrics.service.spec.ts | 70 ++++++ src/modules/metrics/worker-metrics.service.ts | 59 +++++ src/modules/webhooks/webhook.module.ts | 2 + src/modules/webhooks/webhooks.processor.ts | 150 ++++++------ src/workers/analytics-aggregation.worker.ts | 24 +- src/workers/balance.worker.ts | 33 ++- src/workers/notification-delivery.worker.ts | 24 +- src/workers/webhook-delivery.worker.ts | 28 ++- src/workers/workers.module.ts | 8 +- 17 files changed, 890 insertions(+), 114 deletions(-) create mode 100644 src/common/middleware/raw-body.middleware.spec.ts create mode 100644 src/common/middleware/raw-body.middleware.ts create mode 100644 src/modules/metrics/worker-metrics.service.spec.ts create mode 100644 src/modules/metrics/worker-metrics.service.ts diff --git a/src/common/guards/webhook-signature.guard.spec.ts b/src/common/guards/webhook-signature.guard.spec.ts index f4d62e7..9bf6772 100644 --- a/src/common/guards/webhook-signature.guard.spec.ts +++ b/src/common/guards/webhook-signature.guard.spec.ts @@ -1,8 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; import { WebhookSignatureGuard } from './webhook-signature.guard'; +import type { WebhookSecretResolver } from './webhook-signature.guard'; import { WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, @@ -91,8 +92,216 @@ describe('WebhookSignatureGuard', () => { expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); }); - it('should reject requests with missing headers', () => { - const context = createMockContext({}, { foo: 'bar' }); + it('should reject requests with missing signature header', () => { + const context = createMockContext( + { [WEBHOOK_TIMESTAMP_HEADER]: Math.floor(Date.now() / 1000).toString() }, + { foo: 'bar' }, + ); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject requests with missing timestamp header', () => { + const context = createMockContext( + { [WEBHOOK_SIGNATURE_HEADER]: 'abc123' }, + { foo: 'bar' }, + ); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject requests with non-numeric timestamp', () => { + const context = createMockContext( + { + [WEBHOOK_SIGNATURE_HEADER]: 'abc123', + [WEBHOOK_TIMESTAMP_HEADER]: 'not-a-number', + }, + { foo: 'bar' }, + ); expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); }); + + it('should reject when no secret is configured', () => { + const noSecretConfig = { + get: () => null, + } as unknown as ConfigService; + const guardNoSecret = new WebhookSignatureGuard(noSecretConfig); + + const timestamp = Math.floor(Date.now() / 1000); + const context = createMockContext( + { + [WEBHOOK_SIGNATURE_HEADER]: 'abc123', + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + }, + { foo: 'bar' }, + ); + + expect(() => guardNoSecret.canActivate(context)).toThrow(UnauthorizedException); + }); + + describe('per-integration secret resolver', () => { + it('should use custom secret resolver when provided', () => { + const customSecret = 'integration-specific-secret'; + const secretResolver: WebhookSecretResolver = vi.fn().mockReturnValue(customSecret); + + const guardWithResolver = new WebhookSignatureGuard(mockConfigService, { + secretResolver, + }); + + const timestamp = Math.floor(Date.now() / 1000); + const body = { event: 'test' }; + const rawBody = JSON.stringify(body); + + const hmac = crypto.createHmac('sha256', customSecret); + hmac.update(`${timestamp}.${rawBody}`); + const signature = hmac.digest('hex'); + + const context = createMockContext( + { + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + 'x-integration-id': 'integration-123', + }, + body, + ); + + expect(guardWithResolver.canActivate(context)).toBe(true); + expect(secretResolver).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.any(Object) }), + 'integration-123', + ); + }); + + it('should fall back to global secret when resolver returns undefined', () => { + const secretResolver: WebhookSecretResolver = vi.fn().mockReturnValue(undefined); + + const guardWithResolver = new WebhookSignatureGuard(mockConfigService, { + secretResolver, + }); + + const timestamp = Math.floor(Date.now() / 1000); + const body = { event: 'test' }; + const rawBody = JSON.stringify(body); + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(`${timestamp}.${rawBody}`); + const signature = hmac.digest('hex'); + + const context = createMockContext( + { + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + }, + body, + ); + + expect(guardWithResolver.canActivate(context)).toBe(true); + }); + + it('should reject with custom secret when signature does not match', () => { + const customSecret = 'integration-specific-secret'; + const secretResolver: WebhookSecretResolver = vi.fn().mockReturnValue(customSecret); + + const guardWithResolver = new WebhookSignatureGuard(mockConfigService, { + secretResolver, + }); + + const timestamp = Math.floor(Date.now() / 1000); + const body = { event: 'test' }; + const rawBody = JSON.stringify(body); + + // Sign with the WRONG secret + const hmac = crypto.createHmac('sha256', 'wrong-secret'); + hmac.update(`${timestamp}.${rawBody}`); + const signature = hmac.digest('hex'); + + const context = createMockContext( + { + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + }, + body, + ); + + expect(() => guardWithResolver.canActivate(context)).toThrow(UnauthorizedException); + }); + }); + + describe('custom tolerance', () => { + it('should respect custom tolerance window', () => { + const guardShortTolerance = new WebhookSignatureGuard(mockConfigService, { + toleranceSeconds: 60, // 1 minute + }); + + const timestamp = Math.floor(Date.now() / 1000) - 90; // 90 seconds ago + const body = { event: 'test' }; + const rawBody = JSON.stringify(body); + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(`${timestamp}.${rawBody}`); + const signature = hmac.digest('hex'); + + const context = createMockContext( + { + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + }, + body, + ); + + expect(() => guardShortTolerance.canActivate(context)).toThrow(UnauthorizedException); + }); + }); + + describe('raw body extraction', () => { + it('should prefer rawBody over parsed body for HMAC computation', () => { + const timestamp = Math.floor(Date.now() / 1000); + const rawBody = '{"event":"test","data":{"a":1}}'; + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(`${timestamp}.${rawBody}`); + const signature = hmac.digest('hex'); + + const request = { + headers: { + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + }, + body: { event: 'test', data: { a: 1 } }, // parsed body (may have different key order) + rawBody: Buffer.from(rawBody), + }; + + const context = { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should fall back to JSON.stringify when rawBody is not set', () => { + const timestamp = Math.floor(Date.now() / 1000); + const body = { event: 'test' }; + const rawBody = JSON.stringify(body); + + const hmac = crypto.createHmac('sha256', secret); + hmac.update(`${timestamp}.${rawBody}`); + const signature = hmac.digest('hex'); + + const request = { + headers: { + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp.toString(), + }, + body, + }; + + const context = { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; + + expect(guard.canActivate(context)).toBe(true); + }); + }); }); diff --git a/src/common/guards/webhook-signature.guard.ts b/src/common/guards/webhook-signature.guard.ts index f70af35..de2db40 100644 --- a/src/common/guards/webhook-signature.guard.ts +++ b/src/common/guards/webhook-signature.guard.ts @@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, + Logger, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -13,14 +14,67 @@ import { } from '../constants/headers'; /** - * Validates HMAC-SHA256 signature and timestamp freshness on incoming Stellar Horizon/Soroban webhook events. + * Resolves the HMAC secret for a given webhook integration. + * Returns the secret string, or undefined if no secret is configured. + */ +export type WebhookSecretResolver = ( + request: Request, + integrationId?: string, +) => string | undefined | Promise; + +/** + * Configuration options for `WebhookSignatureGuard`. + */ +export interface WebhookSignatureGuardOptions { + /** + * Custom secret resolver for per-integration webhook secrets. + * When provided, this function is called first to resolve the secret. + * If it returns `undefined`, the guard falls back to global env-var secrets. + */ + secretResolver?: WebhookSecretResolver; + /** + * Timestamp tolerance in seconds (default: 300 = 5 minutes). + * Requests with timestamps older than this are rejected to prevent replay attacks. + */ + toleranceSeconds?: number; +} + +/** + * Validates HMAC-SHA256 signature and timestamp freshness on incoming + * webhook events from external partner services and oracle providers. + * * Signature calculation: HMAC-SHA256(timestamp + '.' + rawBody, secret) + * + * Security features: + * - Constant-time comparison via `crypto.timingSafeEqual` prevents timing attacks + * - Timestamp tolerance (default 5 min) prevents replay attacks + * - Configurable per-integration secret resolution for multi-tenant setups + * - Raw body buffering ensures accurate HMAC computation + * + * @example + * ```ts + * // Apply with default global secret: + * @UseGuards(WebhookSignatureGuard) + * + * // Apply with per-integration secret resolver: + * @UseGuards(new WebhookSignatureGuard(configService, { + * secretResolver: (req) => getIntegrationSecret(req.headers['x-integration-id']), + * })) + * ``` */ @Injectable() export class WebhookSignatureGuard implements CanActivate { - private readonly toleranceSeconds = 300; // 5 minutes + private readonly logger = new Logger(WebhookSignatureGuard.name); + private readonly toleranceSeconds: number; + private readonly secretResolver?: WebhookSecretResolver; - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + options?: WebhookSignatureGuardOptions, + ) { + this.toleranceSeconds = options?.toleranceSeconds ?? 300; + this.secretResolver = options?.secretResolver; + } canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); @@ -46,16 +100,14 @@ export class WebhookSignatureGuard implements CanActivate { throw new UnauthorizedException('Webhook timestamp expired or out of tolerance'); } - const secret = - this.configService.get('WEBHOOK_SECRET') || - this.configService.get('STELLAR_WEBHOOK_SECRET') || - 'astroid-webhook-secret-key-default'; + // Resolve the secret: try per-integration resolver first, then fall back to global env vars. + const secret = this.resolveSecret(request); + if (!secret) { + this.logger.warn('No webhook secret configured; rejecting request'); + throw new UnauthorizedException('No webhook signing secret configured'); + } - const payload = - (request as Request & { rawBody?: Buffer | string }).rawBody || - (typeof request.body === 'string' - ? request.body - : JSON.stringify(request.body ?? {})); + const payload = this.extractPayload(request); const expectedPayloadToSign = `${timestamp}.${payload}`; const hmac = crypto.createHmac('sha256', secret); @@ -74,4 +126,53 @@ export class WebhookSignatureGuard implements CanActivate { return true; } + + /** + * Resolves the HMAC signing secret for the incoming request. + * Priority: per-integration resolver → WEBHOOK_SECRET → STELLAR_WEBHOOK_SECRET. + */ + private resolveSecret(request: Request): string | undefined { + // Per-integration secret resolver (synchronous or async) + const integrationId = request.headers['x-integration-id'] as string | undefined; + if (this.secretResolver) { + const resolved = this.secretResolver(request, integrationId); + // Handle both sync and async resolvers + if (resolved && typeof (resolved as Promise).then === 'function') { + // Async resolver — store the promise for later use + // Note: canActivate doesn't support async in sync mode, so we + // log a warning. For async secret resolution, use the async guard variant. + this.logger.warn( + 'Async secret resolver returned a Promise; use the async canActivate variant for async secret resolution', + ); + } else if (resolved) { + return resolved as string; + } + } + + // Global env-var fallback + return ( + this.configService.get('WEBHOOK_SECRET') || + this.configService.get('STELLAR_WEBHOOK_SECRET') || + this.configService.get('WEBHOOK_SIGNING_SECRET') || + undefined + ); + } + + /** + * Extracts the request payload for HMAC computation. + * Prefers the raw body captured by `RawBodyMiddleware`, falling back to + * the parsed body if raw body is not available. + */ + private extractPayload(request: Request): string { + const rawBody = (request as Request & { rawBody?: Buffer | string }).rawBody; + if (rawBody !== undefined) { + return Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody; + } + + if (typeof request.body === 'string') { + return request.body; + } + + return JSON.stringify(request.body ?? {}); + } } diff --git a/src/common/middleware/raw-body.middleware.spec.ts b/src/common/middleware/raw-body.middleware.spec.ts new file mode 100644 index 0000000..f798951 --- /dev/null +++ b/src/common/middleware/raw-body.middleware.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RawBodyMiddleware } from './raw-body.middleware'; + +function buildRequest(method = 'POST', body?: unknown) { + const listeners: Record void> = {}; + return { + method, + body, + rawBody: undefined as Buffer | undefined, + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + listeners[event] = cb; + return { on: vi.fn() }; + }), + _emit: (event: string, ...args: unknown[]) => listeners[event]?.(...args), + }; +} + +describe('RawBodyMiddleware', () => { + it('skips non-POST/PUT/PATCH methods', () => { + const middleware = new RawBodyMiddleware(); + const req = buildRequest('GET'); + const next = vi.fn(); + + middleware.use(req as any, {} as any, next); + + expect(next).toHaveBeenCalled(); + expect(req.rawBody).toBeUndefined(); + }); + + it('registers stream listeners to capture raw body for POST requests', () => { + const middleware = new RawBodyMiddleware(); + const req = buildRequest('POST'); + const next = vi.fn(); + + middleware.use(req as any, {} as any, next); + expect(next).toHaveBeenCalled(); + + // Verify 'data' and 'end' listeners were registered + expect(req.on).toHaveBeenCalledWith('data', expect.any(Function)); + expect(req.on).toHaveBeenCalledWith('end', expect.any(Function)); + }); + + it('re-serializes parsed body when rawBody is not set', () => { + const middleware = new RawBodyMiddleware(); + const req = buildRequest('POST', { event: 'test' }); + const next = vi.fn(); + + middleware.use(req as any, {} as any, next); + + expect(next).toHaveBeenCalled(); + expect(req.rawBody).toBeDefined(); + expect(req.rawBody!.toString()).toBe('{"event":"test"}'); + }); + + it('handles string body', () => { + const middleware = new RawBodyMiddleware(); + const req = buildRequest('POST', '{"event":"test"}'); + const next = vi.fn(); + + middleware.use(req as any, {} as any, next); + + expect(next).toHaveBeenCalled(); + expect(req.rawBody).toBeDefined(); + expect(req.rawBody!.toString()).toBe('{"event":"test"}'); + }); + + it('handles PUT method', () => { + const middleware = new RawBodyMiddleware(); + const req = buildRequest('PUT', { update: true }); + const next = vi.fn(); + + middleware.use(req as any, {} as any, next); + + expect(next).toHaveBeenCalled(); + expect(req.rawBody).toBeDefined(); + }); + + it('handles PATCH method', () => { + const middleware = new RawBodyMiddleware(); + const req = buildRequest('PATCH', { patch: true }); + const next = vi.fn(); + + middleware.use(req as any, {} as any, next); + + expect(next).toHaveBeenCalled(); + expect(req.rawBody).toBeDefined(); + }); +}); diff --git a/src/common/middleware/raw-body.middleware.ts b/src/common/middleware/raw-body.middleware.ts new file mode 100644 index 0000000..ee77097 --- /dev/null +++ b/src/common/middleware/raw-body.middleware.ts @@ -0,0 +1,62 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +/** + * Captures the raw request body as a Buffer before Express parses it as JSON. + * + * Webhook signature verification (HMAC-SHA256) requires the exact bytes that + * were signed by the sender. If Express's `express.json()` parser runs first, + * the body is re-serialized via `JSON.stringify`, which can change key ordering, + * whitespace, and Unicode encoding — invalidating the signature. + * + * This middleware stores the raw body on `request.rawBody` so the + * `WebhookSignatureGuard` can use it for HMAC computation. + * + * Apply this middleware ONLY to webhook ingress routes (e.g. `/webhooks/receive`) + * to avoid buffering every request in the application. + */ +@Injectable() +export class RawBodyMiddleware implements NestMiddleware { + use(req: Request, _res: Response, next: NextFunction): void { + // Buffer is only needed for POST/PUT/PATCH; skip others. + if (!['POST', 'PUT', 'PATCH'].includes(req.method)) { + next(); + return; + } + + // If express.json() has already consumed the stream, the body is on req.body. + // In that case, re-serialize to get a Buffer the guard can use. + if (req.body !== undefined && req.rawBody === undefined) { + const raw = + typeof req.body === 'string' + ? Buffer.from(req.body) + : Buffer.from(JSON.stringify(req.body)); + req.rawBody = raw; + next(); + return; + } + + // Capture the raw request body by buffering the incoming data stream. + // This must run BEFORE express.json() so we intercept the bytes first. + const chunks: Buffer[] = []; + + req.on('data', (chunk: Buffer) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + req.on('end', () => { + req.rawBody = Buffer.concat(chunks); + }); + + next(); + } +} + +// Extend Express Request to include rawBody +declare global { + namespace Express { + interface Request { + rawBody?: Buffer; + } + } +} diff --git a/src/modules/metrics/index.ts b/src/modules/metrics/index.ts index c7506e2..eb573cc 100644 --- a/src/modules/metrics/index.ts +++ b/src/modules/metrics/index.ts @@ -3,3 +3,4 @@ export * from './metrics.controller'; export * from './metrics.service'; export * from './metrics-access.guard'; export * from './metrics.middleware'; +export * from './worker-metrics.service'; diff --git a/src/modules/metrics/metrics.module.ts b/src/modules/metrics/metrics.module.ts index 0170afe..05f0595 100644 --- a/src/modules/metrics/metrics.module.ts +++ b/src/modules/metrics/metrics.module.ts @@ -3,16 +3,20 @@ import { MetricsController } from './metrics.controller'; import { MetricsService } from './metrics.service'; import { MetricsAccessGuard } from './metrics-access.guard'; import { RequestMetricsMiddleware } from './metrics.middleware'; +import { WorkerMetricsService } from './worker-metrics.service'; /** * Prometheus metrics module: HTTP duration/counter collection - * (`RequestMetricsMiddleware`) and the `/metrics` scrape endpoint. - * `MetricsService` is exported so other modules (e.g. workers) could record - * custom metrics against the same registry in the future. + * (`RequestMetricsMiddleware`), the `/metrics` scrape endpoint, + * and worker job latency/outcome tracking (`WorkerMetricsService`). + * + * Both `MetricsService` and `WorkerMetricsService` are exported so + * workers and other modules can record custom metrics against the + * shared Prometheus registry. */ @Module({ controllers: [MetricsController], - providers: [MetricsService, MetricsAccessGuard, RequestMetricsMiddleware], - exports: [MetricsService], + providers: [MetricsService, MetricsAccessGuard, RequestMetricsMiddleware, WorkerMetricsService], + exports: [MetricsService, WorkerMetricsService], }) export class MetricsModule {} diff --git a/src/modules/metrics/metrics.service.spec.ts b/src/modules/metrics/metrics.service.spec.ts index 57505c4..d8d7aec 100644 --- a/src/modules/metrics/metrics.service.spec.ts +++ b/src/modules/metrics/metrics.service.spec.ts @@ -82,4 +82,50 @@ describe('MetricsService', () => { expect(close).toHaveBeenCalled(); }); + + describe('worker job metrics', () => { + it('records successful job completion in the duration histogram', async () => { + service.recordJobCompletion('webhooks', 'deliver', 0.25, 'success'); + + const output = await service.getMetrics(); + + expect(output).toContain('worker_job_duration_seconds'); + expect(output).toContain('queue="webhooks"'); + expect(output).toContain('job_name="deliver"'); + expect(output).toContain('result="success"'); + }); + + it('records failed job completion', async () => { + service.recordJobCompletion('transactions', 'execute', 1.5, 'failure'); + + const output = await service.getMetrics(); + + expect(output).toContain('worker_jobs_total'); + expect(output).toContain('queue="transactions"'); + expect(output).toContain('job_name="execute"'); + expect(output).toContain('result="failure"'); + }); + + it('increments job counter across multiple completions', async () => { + service.recordJobCompletion('webhooks', 'deliver', 0.1, 'success'); + service.recordJobCompletion('webhooks', 'deliver', 0.2, 'success'); + service.recordJobCompletion('webhooks', 'deliver', 0.3, 'failure'); + + const output = await service.getMetrics(); + + // Check success count is 2 + const successMatch = output.match( + /worker_jobs_total\{queue="webhooks",job_name="deliver",result="success"\} (\d+)/, + ); + expect(successMatch).not.toBeNull(); + expect(successMatch?.[1]).toBe('2'); + + // Check failure count is 1 + const failureMatch = output.match( + /worker_jobs_total\{queue="webhooks",job_name="deliver",result="failure"\} (\d+)/, + ); + expect(failureMatch).not.toBeNull(); + expect(failureMatch?.[1]).toBe('1'); + }); + }); }); diff --git a/src/modules/metrics/metrics.service.ts b/src/modules/metrics/metrics.service.ts index b4c193e..f66b182 100644 --- a/src/modules/metrics/metrics.service.ts +++ b/src/modules/metrics/metrics.service.ts @@ -22,11 +22,16 @@ const QUEUE_JOB_STATES = [ /** * Owns the Prometheus metrics registry: HTTP request duration/count - * (recorded by `RequestMetricsMiddleware`) and BullMQ queue depth gauges - * (sampled on demand at scrape time so counts are always current). + * (recorded by `RequestMetricsMiddleware`), BullMQ queue depth gauges + * (sampled on demand at scrape time so counts are always current), + * and worker processing latency / outcome counters. * * Read-only `Queue` handles are opened here purely to poll job counts — * no jobs are ever added or processed through them. + *\ * Worker metrics are recorded by injecting this service and calling + * `recordJobCompletion()` after each job finishes. The histogram tracks + * processing latency and the counter tracks outcomes (success vs failure) + * per queue and job name. */ @Injectable() export class MetricsService implements OnModuleDestroy { @@ -36,6 +41,8 @@ export class MetricsService implements OnModuleDestroy { private readonly httpRequestDuration: Histogram<'method' | 'route' | 'status_code'>; private readonly httpRequestsTotal: Counter<'method' | 'route' | 'status_code'>; private readonly queueJobsGauge: Gauge<'queue' | 'state'>; + private readonly workerJobDuration: Histogram<'queue' | 'job_name' | 'result'>; + private readonly workerJobsTotal: Counter<'queue' | 'job_name' | 'result'>; private readonly queueHandles: Map; constructor() { @@ -67,6 +74,30 @@ export class MetricsService implements OnModuleDestroy { await this.sampleQueueDepths(); }, }); + + /** + * Worker processing latency histogram. Tracks how long each job takes + * to process, broken down by queue and job name. Buckets are tuned for + * typical async workloads (sub-second to multi-second processing times). + */ + this.workerJobDuration = new Histogram({ + name: 'worker_job_duration_seconds', + help: 'Worker job processing duration in seconds', + labelNames: ['queue', 'job_name', 'result'], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], + registers: [this.registry], + }); + + /** + * Worker job outcome counter. Incremented on every job completion + * (success or failure) to provide failure-rate calculations. + */ + this.workerJobsTotal = new Counter({ + name: 'worker_jobs_total', + help: 'Total number of worker jobs processed, by outcome', + labelNames: ['queue', 'job_name', 'result'], + registers: [this.registry], + }); } /** Records one completed HTTP request against the duration/count metrics. */ @@ -76,6 +107,26 @@ export class MetricsService implements OnModuleDestroy { this.httpRequestsTotal.inc(labels); } + /** + * Records a worker job completion. Call this after each BullMQ job finishes + * to track processing latency and outcome. + * + * @param queue Queue name (e.g. "webhooks", "transactions") + * @param jobName Job name or identifier + * @param durationSeconds Processing time in seconds + * @param result "success" or "failure" + */ + recordJobCompletion( + queue: string, + jobName: string, + durationSeconds: number, + result: 'success' | 'failure', + ): void { + const labels = { queue, job_name: jobName, result }; + this.workerJobDuration.observe(labels, durationSeconds); + this.workerJobsTotal.inc(labels); + } + /** Renders the registry in Prometheus text exposition format. */ async getMetrics(): Promise { return this.registry.metrics(); diff --git a/src/modules/metrics/worker-metrics.service.spec.ts b/src/modules/metrics/worker-metrics.service.spec.ts new file mode 100644 index 0000000..7b21d68 --- /dev/null +++ b/src/modules/metrics/worker-metrics.service.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WorkerMetricsService } from './worker-metrics.service'; +import { MetricsService } from './metrics.service'; + +function buildMetricsService(): MetricsService { + return { recordJobCompletion: vi.fn() } as unknown as MetricsService; +} + +describe('WorkerMetricsService', () => { + it('records success metrics when job completes', async () => { + const metricsService = buildMetricsService(); + const workerMetrics = new WorkerMetricsService(metricsService); + + const result = await workerMetrics.instrumentJob('webhooks', 'deliver', async () => { + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(metricsService.recordJobCompletion).toHaveBeenCalledWith( + 'webhooks', + 'deliver', + expect.any(Number), + 'success', + ); + }); + + it('records failure metrics when job throws', async () => { + const metricsService = buildMetricsService(); + const workerMetrics = new WorkerMetricsService(metricsService); + + await expect( + workerMetrics.instrumentJob('transactions', 'execute', async () => { + throw new Error('insufficient funds'); + }), + ).rejects.toThrow('insufficient funds'); + + expect(metricsService.recordJobCompletion).toHaveBeenCalledWith( + 'transactions', + 'execute', + expect.any(Number), + 'failure', + ); + }); + + it('re-throws the original error after recording failure', async () => { + const metricsService = buildMetricsService(); + const workerMetrics = new WorkerMetricsService(metricsService); + const originalError = new TypeError('bad payload'); + + await expect( + workerMetrics.instrumentJob('notifications', 'send', async () => { + throw originalError; + }), + ).rejects.toThrow(originalError); + }); + + it('records positive duration for successful jobs', async () => { + const metricsService = buildMetricsService(); + const workerMetrics = new WorkerMetricsService(metricsService); + + await workerMetrics.instrumentJob('analytics', 'rollup', async () => { + // Simulate some work + await new Promise((resolve) => setTimeout(resolve, 10)); + return undefined; + }); + + const call = (metricsService.recordJobCompletion as ReturnType).mock.calls[0]; + expect(call[2]).toBeGreaterThan(0); + }); +}); diff --git a/src/modules/metrics/worker-metrics.service.ts b/src/modules/metrics/worker-metrics.service.ts new file mode 100644 index 0000000..206ef37 --- /dev/null +++ b/src/modules/metrics/worker-metrics.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +/** + * Wraps BullMQ job processing with automatic Prometheus metric collection. + * + * Inject this service in any worker and call `instrumentJob` to track + * processing latency (`worker_job_duration_seconds` histogram) and + * outcome (`worker_jobs_total` counter) per queue and job name. + * + * @example + * ```ts + * constructor(private readonly workerMetrics: WorkerMetricsService) {} + * + * async process(job: Job): Promise { + * return this.workerMetrics.instrumentJob('webhooks', job.name, async () => { + * // ... actual job processing ... + * return result; + * }); + * } + * ``` + */ +@Injectable() +export class WorkerMetricsService { + private readonly logger = new Logger(WorkerMetricsService.name); + + constructor(private readonly metricsService: MetricsService) {} + + /** + * Instruments a job processing function with latency and outcome tracking. + * On success, records the duration with result="success". + * On failure, records the duration with result="failure" and re-throws. + * + * @param queue Queue name (e.g. "webhooks", "transactions") + * @param jobName Job name or identifier for labeling + * @param fn The actual job processing function to instrument + * @returns The result of the processing function + */ + async instrumentJob( + queue: string, + jobName: string, + fn: () => Promise, + ): Promise { + const start = process.hrtime.bigint(); + try { + const result = await fn(); + const durationSeconds = Number(process.hrtime.bigint() - start) / 1e9; + this.metricsService.recordJobCompletion(queue, jobName, durationSeconds, 'success'); + return result; + } catch (error) { + const durationSeconds = Number(process.hrtime.bigint() - start) / 1e9; + this.metricsService.recordJobCompletion(queue, jobName, durationSeconds, 'failure'); + this.logger.debug( + `Job "${jobName}" on queue "${queue}" failed after ${durationSeconds.toFixed(3)}s: ${(error as Error).message}`, + ); + throw error; + } + } +} diff --git a/src/modules/webhooks/webhook.module.ts b/src/modules/webhooks/webhook.module.ts index 19fa4bf..f8fee92 100644 --- a/src/modules/webhooks/webhook.module.ts +++ b/src/modules/webhooks/webhook.module.ts @@ -10,6 +10,7 @@ import { WebhooksProcessor } from './webhooks.processor'; import { Queues } from '../../queues/queues.constants'; import { redisConfig } from '../../config/redis.config'; import { webhookBackoffStrategy } from '../../utils/backoff.util'; +import { MetricsModule } from '../metrics/metrics.module'; import type { RegisterQueueOptions } from '@nestjs/bullmq'; /** @@ -48,6 +49,7 @@ import type { RegisterQueueOptions } from '@nestjs/bullmq'; backoffStrategy: webhookBackoffStrategy, } as RegisterQueueOptions['settings'], }), + MetricsModule, ], controllers: [WebhookController], providers: [ diff --git a/src/modules/webhooks/webhooks.processor.ts b/src/modules/webhooks/webhooks.processor.ts index e75b932..f872f03 100644 --- a/src/modules/webhooks/webhooks.processor.ts +++ b/src/modules/webhooks/webhooks.processor.ts @@ -6,6 +6,7 @@ import { Queues } from '../../queues/queues.constants'; import { WebhookJobData, WebhookJobResult } from './types/webhook-job.types'; import { generateWebhookSignature } from '../../utils/crypto.util'; import { PrismaService } from '../../database/prisma.service'; +import { WorkerMetricsService } from '../../modules/metrics/worker-metrics.service'; /** * BullMQ job processor for webhook event delivery with exponential backoff + jitter. @@ -21,6 +22,9 @@ import { PrismaService } from '../../database/prisma.service'; * queue registration (see webhook.module.ts). BullMQ reads the strategy from * queue.opts.settings.backoffStrategy at retry time. * + * Processing latency and outcomes are recorded against the Prometheus registry + * via `WorkerMetricsService` when available. + * * This processor mirrors workers/webhook.worker.ts and is registered as an * alias to satisfy the expected import path `src/modules/webhooks/webhooks.processor.ts`. */ @@ -32,6 +36,7 @@ export class WebhooksProcessor extends WorkerHost { constructor( @Optional() @Inject(PrismaService) private readonly prisma?: PrismaService, @Optional() private readonly configService?: ConfigService, + @Optional() private readonly workerMetrics?: WorkerMetricsService, ) { super(); } @@ -47,90 +52,99 @@ export class WebhooksProcessor extends WorkerHost { } async process(job: Job): Promise { - const { webhookId, organizationId, url, secret, eventName, payload, eventId } = job.data; - this.logger.debug(`Processing webhook ${webhookId} event ${eventName} attempt ${job.attemptsMade + 1}/5`); + const jobName = job.name ?? 'webhook-delivery'; - let responseStatus: number | undefined; - let errorMessage: string | undefined; - let isNonTransient = false; + const execute = async (): Promise => { + const { webhookId, organizationId, url, secret, eventName, payload, eventId } = job.data; + this.logger.debug(`Processing webhook ${webhookId} event ${eventName} attempt ${job.attemptsMade + 1}/5`); - try { - const body = JSON.stringify(payload); - const timestamp = Math.floor(Date.now() / 1000).toString(); - const effectiveSecret = this.resolveSecret(secret); - const signature = generateWebhookSignature(effectiveSecret, timestamp, body); + let responseStatus: number | undefined; + let errorMessage: string | undefined; + let isNonTransient = false; - const response = await fetch(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-astroid-signature': signature, - 'x-astroid-timestamp': timestamp, - 'x-astroid-delivery': eventId, - 'x-astroid-event': eventName, - 'x-astroid-event-id': eventId, - 'user-agent': 'Astroid-Webhook-Bot/1.0', - }, - body, - signal: AbortSignal.timeout(5000), - }); + try { + const body = JSON.stringify(payload); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const effectiveSecret = this.resolveSecret(secret); + const signature = generateWebhookSignature(effectiveSecret, timestamp, body); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-astroid-signature': signature, + 'x-astroid-timestamp': timestamp, + 'x-astroid-delivery': eventId, + 'x-astroid-event': eventName, + 'x-astroid-event-id': eventId, + 'user-agent': 'Astroid-Webhook-Bot/1.0', + }, + body, + signal: AbortSignal.timeout(5000), + }); - responseStatus = response.status; - if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - errorMessage = `HTTP ${response.status}: ${errorText}`; - isNonTransient = WebhooksProcessor.NON_TRANSIENT_STATUSES.has(response.status); - this.logger.warn(`Webhook ${webhookId} responded ${response.status}: ${errorText}`); - if (isNonTransient) { - await this.persistState({ - webhookId, - organizationId, - eventName, - eventId, - payload, - status: 'FAILED', - attempts: job.attemptsMade + 1, - lastError: errorMessage, - responseStatus, - }); - throw new UnrecoverableError(errorMessage); + responseStatus = response.status; + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + errorMessage = `HTTP ${response.status}: ${errorText}`; + isNonTransient = WebhooksProcessor.NON_TRANSIENT_STATUSES.has(response.status); + this.logger.warn(`Webhook ${webhookId} responded ${response.status}: ${errorText}`); + if (isNonTransient) { + await this.persistState({ + webhookId, + organizationId, + eventName, + eventId, + payload, + status: 'FAILED', + attempts: job.attemptsMade + 1, + lastError: errorMessage, + responseStatus, + }); + throw new UnrecoverableError(errorMessage); + } + throw new Error(errorMessage); } - throw new Error(errorMessage); + this.logger.debug(`Webhook ${webhookId} delivered successfully`); + } catch (error) { + if (error instanceof UnrecoverableError) throw error; + errorMessage = (error as Error).message; + const isLastAttempt = job.attemptsMade >= 4; + this.logger.error(`Webhook ${webhookId} failed attempt ${job.attemptsMade + 1}/5: ${errorMessage}`); + await this.persistState({ + webhookId, + organizationId, + eventName, + eventId, + payload, + status: isLastAttempt ? 'FAILED' : 'RETRYING', + attempts: job.attemptsMade + 1, + lastError: errorMessage, + responseStatus, + }); + if (isLastAttempt) { + this.logger.error(`Webhook ${webhookId} exhausted all retry attempts`); + } + throw error; } - this.logger.debug(`Webhook ${webhookId} delivered successfully`); - } catch (error) { - if (error instanceof UnrecoverableError) throw error; - errorMessage = (error as Error).message; - const isLastAttempt = job.attemptsMade >= 4; - this.logger.error(`Webhook ${webhookId} failed attempt ${job.attemptsMade + 1}/5: ${errorMessage}`); + await this.persistState({ webhookId, organizationId, eventName, eventId, payload, - status: isLastAttempt ? 'FAILED' : 'RETRYING', + status: 'DELIVERED', attempts: job.attemptsMade + 1, - lastError: errorMessage, responseStatus, }); - if (isLastAttempt) { - this.logger.error(`Webhook ${webhookId} exhausted all retry attempts`); - } - throw error; - } + return { success: true, statusCode: responseStatus }; + }; - await this.persistState({ - webhookId, - organizationId, - eventName, - eventId, - payload, - status: 'DELIVERED', - attempts: job.attemptsMade + 1, - responseStatus, - }); - return { success: true, statusCode: responseStatus }; + if (this.workerMetrics) { + return this.workerMetrics.instrumentJob(Queues.Webhooks, jobName, execute); + } + return execute(); } private async persistState(data: { diff --git a/src/workers/analytics-aggregation.worker.ts b/src/workers/analytics-aggregation.worker.ts index ac75734..ec51886 100644 --- a/src/workers/analytics-aggregation.worker.ts +++ b/src/workers/analytics-aggregation.worker.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { Queues } from '../queues/queues.constants'; +import { WorkerMetricsService } from '../modules/metrics/worker-metrics.service'; export interface AnalyticsRollupJob { organizationId: string; @@ -11,13 +12,30 @@ export interface AnalyticsRollupJob { * Nightly aggregation job that pre-computes cash-flow, budget utilization and * risk distribution so the dashboard reads warm snapshots instead of scanning * the transaction log on every render. + * + * Processing latency and outcomes are recorded against the Prometheus registry + * via `WorkerMetricsService` when available. */ @Injectable() export class AnalyticsAggregationWorker { private readonly logger = new Logger(AnalyticsAggregationWorker.name); readonly queue = Queues.Analytics; - async process(job: { data: AnalyticsRollupJob }): Promise { - this.logger.log(`aggregate ${job.data.date} for org ${job.data.organizationId}`); + constructor( + @Optional() private readonly workerMetrics?: WorkerMetricsService, + ) {} + + async process(job: { data: AnalyticsRollupJob; name?: string }): Promise { + const jobName = job.name ?? 'analytics-rollup'; + + const execute = async (): Promise => { + this.logger.log(`aggregate ${job.data.date} for org ${job.data.organizationId}`); + }; + + if (this.workerMetrics) { + await this.workerMetrics.instrumentJob(this.queue, jobName, execute); + } else { + await execute(); + } } } diff --git a/src/workers/balance.worker.ts b/src/workers/balance.worker.ts index b2379bf..ae1b1a4 100644 --- a/src/workers/balance.worker.ts +++ b/src/workers/balance.worker.ts @@ -1,9 +1,10 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { STELLAR_CLIENT, StellarClient } from '../integrations/stellar'; import { BalanceCacheService } from '../modules/wallets/services/balance-cache.service'; import { EventBusService } from '../events/event-bus.service'; import { DomainEventName } from '../events/event-names'; import { Queues } from '../queues/queues.constants'; +import { WorkerMetricsService } from '../modules/metrics/worker-metrics.service'; export interface BalanceSyncJob { walletId: string; @@ -18,6 +19,9 @@ export interface BalanceSyncJob { * * Runs rely on the Stellar integration module to read Horizon; the worker * is responsible for caching, threshold detection, and event propagation. + * + * Processing latency and outcomes are recorded against the Prometheus registry + * via `WorkerMetricsService` when available. */ @Injectable() export class BalanceWorker { @@ -28,20 +32,26 @@ export class BalanceWorker { @Inject(STELLAR_CLIENT) private readonly stellarClient: StellarClient, private readonly cacheService: BalanceCacheService, private readonly eventBus: EventBusService, + @Optional() private readonly workerMetrics?: WorkerMetricsService, ) {} - async process(job: { data: BalanceSyncJob }): Promise<{ + async process(job: { data: BalanceSyncJob; name?: string }): Promise<{ address: string; balanceCount: number; alerts: Array<{ asset: string; balance: string; threshold: number }>; }> { + const jobName = job.name ?? 'balance-sync'; const { walletId, stellarAddress, network, organizationId } = job.data; - this.logger.log( - `Syncing balance for ${stellarAddress} on ${network} (wallet ${walletId})`, - ); + const execute = async (): Promise<{ + address: string; + balanceCount: number; + alerts: Array<{ asset: string; balance: string; threshold: number }>; + }> => { + this.logger.log( + `Syncing balance for ${stellarAddress} on ${network} (wallet ${walletId})`, + ); - try { // Fetch live balances from Stellar const balances = await this.stellarClient.getBalances(stellarAddress, network); @@ -98,11 +108,12 @@ export class BalanceWorker { balanceCount: balances.length, alerts, }; - } catch (error) { - this.logger.error( - `Balance sync failed for ${stellarAddress}: ${(error as Error).message}`, - ); - throw error; + }; + + if (this.workerMetrics) { + return this.workerMetrics.instrumentJob(this.queue, jobName, execute); } + + return execute(); } } diff --git a/src/workers/notification-delivery.worker.ts b/src/workers/notification-delivery.worker.ts index 6746e2a..05ebea2 100644 --- a/src/workers/notification-delivery.worker.ts +++ b/src/workers/notification-delivery.worker.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { Queues } from '../queues/queues.constants'; +import { WorkerMetricsService } from '../modules/metrics/worker-metrics.service'; export interface NotificationJobPayload { notificationId: string; @@ -13,15 +14,30 @@ export interface NotificationJobPayload { * Delivers outbound notifications one at a time. Email and chat channels can be * slow or token-bucketed, so sends are isolated to a worker rather than running * in the request path — a blocked SMTP server never delays a payment response. + * + * Processing latency and outcomes are recorded against the Prometheus registry + * via `WorkerMetricsService` when available. */ @Injectable() export class NotificationDeliveryWorker { private readonly logger = new Logger(NotificationDeliveryWorker.name); readonly queue = Queues.Notifications; + constructor( + @Optional() private readonly workerMetrics?: WorkerMetricsService, + ) {} + async process(job: { name: string; data: NotificationJobPayload }): Promise { - this.logger.log(`[${job.name}] deliver ${job.data.channel} → ${job.data.recipient}`); - // Delivery is performed by the Notifications module dispatch layer; this - // worker only owns the queue cadence and retry semantics. + const execute = async (): Promise => { + this.logger.log(`[${job.name}] deliver ${job.data.channel} → ${job.data.recipient}`); + // Delivery is performed by the Notifications module dispatch layer; this + // worker only owns the queue cadence and retry semantics. + }; + + if (this.workerMetrics) { + await this.workerMetrics.instrumentJob(this.queue, job.name, execute); + } else { + await execute(); + } } } diff --git a/src/workers/webhook-delivery.worker.ts b/src/workers/webhook-delivery.worker.ts index 0c05b65..c72bf21 100644 --- a/src/workers/webhook-delivery.worker.ts +++ b/src/workers/webhook-delivery.worker.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { Queues } from '../queues/queues.constants'; +import { WorkerMetricsService } from '../modules/metrics/worker-metrics.service'; export interface WebhookDeliveryJob { webhookId: string; @@ -13,15 +14,32 @@ export interface WebhookDeliveryJob { * Retries failed webhook deliveries with exponential backoff. Every payload is * signed with the webhook's HMAC secret in the dispatcher; this worker only * schedules redelivery and terminal dead-lettering after `attempts` exhausts. + * + * Processing latency and outcomes are recorded against the Prometheus registry + * via `WorkerMetricsService` when available. */ @Injectable() export class WebhookDeliveryWorker { private readonly logger = new Logger(WebhookDeliveryWorker.name); readonly queue = Queues.Webhooks; - async process(job: { data: WebhookDeliveryJob }): Promise { - this.logger.log( - `deliver ${job.data.event} → webhook ${job.data.webhookId} (attempt ${job.data.attempt})`, - ); + constructor( + @Optional() private readonly workerMetrics?: WorkerMetricsService, + ) {} + + async process(job: { data: WebhookDeliveryJob; name?: string }): Promise { + const jobName = job.name ?? 'webhook-delivery'; + + const execute = async (): Promise => { + this.logger.log( + `deliver ${job.data.event} → webhook ${job.data.webhookId} (attempt ${job.data.attempt})`, + ); + }; + + if (this.workerMetrics) { + await this.workerMetrics.instrumentJob(this.queue, jobName, execute); + } else { + await execute(); + } } } diff --git a/src/workers/workers.module.ts b/src/workers/workers.module.ts index 40ed82f..2076384 100644 --- a/src/workers/workers.module.ts +++ b/src/workers/workers.module.ts @@ -4,6 +4,7 @@ import { WebhookDeliveryWorker } from './webhook-delivery.worker'; import { AnalyticsAggregationWorker } from './analytics-aggregation.worker'; import { NotificationDeliveryWorker } from './notification-delivery.worker'; import { WalletModule } from '../modules/wallets/wallet.module'; +import { MetricsModule } from '../modules/metrics/metrics.module'; /** * Background job processors. @@ -12,9 +13,14 @@ import { WalletModule } from '../modules/wallets/wallet.module'; * BullMQ retry + backoff so a flaky third-party (SMTP, Slack, webhook * consumer) never rolls back a financial action. Register workers here; they * are activated by the queue module once Redis is available. + * + * Workers inject `WorkerMetricsService` from the MetricsModule to record + * processing latency and outcomes against the Prometheus registry. This is + * completely optional — workers that don't inject it simply won't emit + * `worker_job_duration_seconds` or `worker_jobs_total` metrics. */ @Module({ - imports: [WalletModule], + imports: [WalletModule, MetricsModule], providers: [ NotificationDeliveryWorker, WebhookDeliveryWorker, From cb8eb90df044a9ee902ea06301e3eb02b15fddbb Mon Sep 17 00:00:00 2001 From: Hotmopo <297505646+Hotmopo@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:38:14 +0000 Subject: [PATCH 2/2] fix: resolve ESLint errors in raw-body middleware Move Express Request type augmentation to a separate .d.ts file to avoid @typescript-eslint/no-namespace, and replace `as any` casts in the test file with properly typed mock objects to satisfy @typescript-eslint/no-explicit-any. Co-Authored-By: Codebuff --- .../middleware/raw-body.middleware.spec.ts | 26 ++++++++++++------- src/common/middleware/raw-body.middleware.ts | 8 ------ src/types/express-raw-body.d.ts | 6 +++++ 3 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 src/types/express-raw-body.d.ts diff --git a/src/common/middleware/raw-body.middleware.spec.ts b/src/common/middleware/raw-body.middleware.spec.ts index f798951..b9d4b01 100644 --- a/src/common/middleware/raw-body.middleware.spec.ts +++ b/src/common/middleware/raw-body.middleware.spec.ts @@ -1,18 +1,26 @@ import { describe, expect, it, vi } from 'vitest'; +import type { Request, Response } from 'express'; import { RawBodyMiddleware } from './raw-body.middleware'; -function buildRequest(method = 'POST', body?: unknown) { +interface MockRequest extends Pick { + rawBody?: Buffer; + on: ReturnType; + _emit: (event: string, ...args: unknown[]) => void; +} + +function buildRequest(method = 'POST', body?: unknown): MockRequest { const listeners: Record void> = {}; - return { + const req: MockRequest = { method, body, - rawBody: undefined as Buffer | undefined, + rawBody: undefined, on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { listeners[event] = cb; return { on: vi.fn() }; }), _emit: (event: string, ...args: unknown[]) => listeners[event]?.(...args), }; + return req; } describe('RawBodyMiddleware', () => { @@ -21,7 +29,7 @@ describe('RawBodyMiddleware', () => { const req = buildRequest('GET'); const next = vi.fn(); - middleware.use(req as any, {} as any, next); + middleware.use(req as unknown as Request, {} as Response, next); expect(next).toHaveBeenCalled(); expect(req.rawBody).toBeUndefined(); @@ -32,7 +40,7 @@ describe('RawBodyMiddleware', () => { const req = buildRequest('POST'); const next = vi.fn(); - middleware.use(req as any, {} as any, next); + middleware.use(req as unknown as Request, {} as Response, next); expect(next).toHaveBeenCalled(); // Verify 'data' and 'end' listeners were registered @@ -45,7 +53,7 @@ describe('RawBodyMiddleware', () => { const req = buildRequest('POST', { event: 'test' }); const next = vi.fn(); - middleware.use(req as any, {} as any, next); + middleware.use(req as unknown as Request, {} as Response, next); expect(next).toHaveBeenCalled(); expect(req.rawBody).toBeDefined(); @@ -57,7 +65,7 @@ describe('RawBodyMiddleware', () => { const req = buildRequest('POST', '{"event":"test"}'); const next = vi.fn(); - middleware.use(req as any, {} as any, next); + middleware.use(req as unknown as Request, {} as Response, next); expect(next).toHaveBeenCalled(); expect(req.rawBody).toBeDefined(); @@ -69,7 +77,7 @@ describe('RawBodyMiddleware', () => { const req = buildRequest('PUT', { update: true }); const next = vi.fn(); - middleware.use(req as any, {} as any, next); + middleware.use(req as unknown as Request, {} as Response, next); expect(next).toHaveBeenCalled(); expect(req.rawBody).toBeDefined(); @@ -80,7 +88,7 @@ describe('RawBodyMiddleware', () => { const req = buildRequest('PATCH', { patch: true }); const next = vi.fn(); - middleware.use(req as any, {} as any, next); + middleware.use(req as unknown as Request, {} as Response, next); expect(next).toHaveBeenCalled(); expect(req.rawBody).toBeDefined(); diff --git a/src/common/middleware/raw-body.middleware.ts b/src/common/middleware/raw-body.middleware.ts index ee77097..edd2f58 100644 --- a/src/common/middleware/raw-body.middleware.ts +++ b/src/common/middleware/raw-body.middleware.ts @@ -52,11 +52,3 @@ export class RawBodyMiddleware implements NestMiddleware { } } -// Extend Express Request to include rawBody -declare global { - namespace Express { - interface Request { - rawBody?: Buffer; - } - } -} diff --git a/src/types/express-raw-body.d.ts b/src/types/express-raw-body.d.ts new file mode 100644 index 0000000..6867359 --- /dev/null +++ b/src/types/express-raw-body.d.ts @@ -0,0 +1,6 @@ +/** Augment Express Request to include the raw body captured by RawBodyMiddleware. */ +declare namespace Express { + interface Request { + rawBody?: Buffer; + } +}