diff --git a/package-lock.json b/package-lock.json index baea5fe..98178d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ "node-cron": "^3.0.3", "stellar-sdk": "^11.3.0", "winston": "^3.14.0", + "winston-daily-rotate-file": "^5.0.0", "zod": "^4.4.3" - "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "jest": "^29.7.0", @@ -61,6 +61,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1635,6 +1636,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -2276,9 +2278,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.379", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", - "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", "dev": true, "license": "ISC" }, @@ -3953,9 +3955,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -5677,6 +5679,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/package.json b/package.json index 45dc22d..35f3965 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "node-cron": "^3.0.3", "stellar-sdk": "^11.3.0", "winston": "^3.14.0", - "zod": "^4.4.3" + "zod": "^4.4.3", "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { diff --git a/src/index.js b/src/index.js index 431c8be..52ee34f 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ const logger = require('./logger'); const cache = require('./services/cache'); const priceRefreshJob = require('./jobs/priceRefresh'); const buildCorsMiddleware = require('./middleware/cors'); +const { requestIdMiddleware } = require('./middleware/requestId'); const { requireApiKey } = require('./middleware/auth'); const pricesRouter = require('./routes/prices'); const alertsRouter = require('./routes/alerts'); @@ -14,6 +15,7 @@ const airdropsRouter = require('./routes/airdrops'); const app = express(); +app.use(requestIdMiddleware); app.use(helmet()); app.use(buildCorsMiddleware(config.corsAllowedOrigins)); app.use(express.json()); @@ -41,8 +43,10 @@ app.use((err, req, res, _next) => { res.status(status).json({ error: err.message || 'Internal server error' }); }); +let server; + if (require.main === module) { - const server = app.listen(config.port, () => { + server = app.listen(config.port, () => { logger.info(`SmartDrop backend running on port ${config.port}`); priceRefreshJob.start(); }); diff --git a/src/logger.js b/src/logger.js index ce527d4..23238e5 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,6 +1,7 @@ const winston = require('winston'); const DailyRotateFile = require('winston-daily-rotate-file'); const { name: serviceName, version } = require('../package.json'); +const { requestContext } = require('./middleware/requestId'); // ==================== LOG LEVEL ==================== const getLogLevel = () => { @@ -49,10 +50,17 @@ const env = process.env.NODE_ENV || 'development'; const logFormat = process.env.LOG_FORMAT || (env === 'production' ? 'json' : 'pretty'); const useJsonFormat = logFormat === 'json'; +// ==================== REQUEST CONTEXT ==================== +const requestIdFormat = winston.format((info) => { + info.requestId = requestContext.getStore()?.requestId ?? 'system'; + return info; +}); + // ==================== BASE FORMATS ==================== const baseFormats = [ winston.format.timestamp({ format: () => new Date().toISOString() }), winston.format.errors({ stack: true }), + requestIdFormat(), redactFormat(), ]; diff --git a/src/middleware/requestId.js b/src/middleware/requestId.js new file mode 100644 index 0000000..fff9187 --- /dev/null +++ b/src/middleware/requestId.js @@ -0,0 +1,17 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +const requestContext = new AsyncLocalStorage(); + +function requestIdMiddleware(req, res, next) { + req.id = crypto.randomUUID(); + res.setHeader('X-Request-ID', req.id); + requestContext.run({ requestId: req.id }, next); +} + +module.exports = { + requestIdMiddleware, + requestContext, +}; diff --git a/test/requestId.test.js b/test/requestId.test.js new file mode 100644 index 0000000..7e8264e --- /dev/null +++ b/test/requestId.test.js @@ -0,0 +1,118 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const { requestIdMiddleware, requestContext } = require('../src/middleware/requestId'); + +function buildTestApp(onRequest) { + const app = express(); + app.use(requestIdMiddleware); + app.get('/test', (req, res) => { + onRequest(req); + res.json({ ok: true }); + }); + return app; +} + +describe('requestId middleware', () => { + test('sets X-Request-ID response header on every response', async () => { + const app = buildTestApp(() => {}); + + const res = await request(app).get('/test'); + + expect(res.status).toBe(200); + expect(res.headers['x-request-id']).toBeDefined(); + expect(res.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + test('attaches the same ID to req.id and the response header', async () => { + let capturedReqId; + const app = buildTestApp((req) => { + capturedReqId = req.id; + }); + + const res = await request(app).get('/test'); + + expect(capturedReqId).toBe(res.headers['x-request-id']); + }); + + test('runs downstream handlers inside AsyncLocalStorage context', async () => { + let storeRequestId; + const app = buildTestApp((req) => { + storeRequestId = requestContext.getStore()?.requestId; + expect(storeRequestId).toBe(req.id); + }); + + const res = await request(app).get('/test'); + + expect(storeRequestId).toBe(res.headers['x-request-id']); + }); +}); + +describe('logger requestId correlation', () => { + let writeSpy; + + beforeEach(() => { + jest.resetModules(); + process.env.LOG_FORMAT = 'json'; + process.env.LOG_LEVEL = 'info'; + writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk, _encoding, cb) => { + if (typeof cb === 'function') cb(); + return true; + }); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + function findLogLine(message) { + return writeSpy.mock.calls + .map(([chunk]) => chunk.toString()) + .find((line) => line.includes(message)); + } + + test('log output includes matching requestId for a given request', async () => { + const { requestIdMiddleware: middleware } = require('../src/middleware/requestId'); + const logger = require('../src/logger'); + + const app = express(); + app.use(middleware); + app.get('/test', (req, res) => { + logger.info('Handling correlated request'); + res.json({ ok: true }); + }); + + const res = await request(app).get('/test'); + const logLine = findLogLine('Handling correlated request'); + + expect(logLine).toBeDefined(); + const parsed = JSON.parse(logLine); + expect(parsed.requestId).toBe(res.headers['x-request-id']); + }); + + test('background tasks log with requestId system', () => { + const logger = require('../src/logger'); + + logger.info('Background task running'); + + const logLine = findLogLine('Background task running'); + expect(logLine).toBeDefined(); + const parsed = JSON.parse(logLine); + expect(parsed.requestId).toBe('system'); + }); +}); + +describe('requestId on app routes', () => { + test('health endpoint returns X-Request-ID header', async () => { + jest.resetModules(); + const { app } = require('../src/index'); + + const res = await request(app).get('/health'); + + expect(res.status).toBe(200); + expect(res.headers['x-request-id']).toBeDefined(); + }); +}); \ No newline at end of file