Skip to content
Merged
11 changes: 10 additions & 1 deletion config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ const config = {
// logging with Morgan - https://github.com/expressjs/morgan
// Can specify one of 'combined', 'common', 'dev', 'short', 'tiny', 'custom'
format: 'custom',
pattern: ':id :email :method :url :status :response-time ms - :res[content-length]', // only for custom format
pattern: ':requestId :id :email :method :url :status :response-time ms - :res[content-length]', // only for custom format
// Structured JSON output for Winston console transport (enable in production)
json: false,
// Winston log level: error, warn, info, http, verbose, debug, silly
level: 'info',
fileLogger: {
directoryPath: process.cwd(),
fileName: 'app.log',
Expand Down Expand Up @@ -90,6 +94,11 @@ const config = {
trust: {
proxy: false,
},
sentry: {
dsn: process.env.DEVKIT_NODE_sentry_dsn || '',
environment: process.env.DEVKIT_NODE_sentry_environment || 'development',
enabled: false,
},
domain: '',
cookie: {
secure: false, // false in dev (HTTP localhost)
Expand Down
9 changes: 8 additions & 1 deletion config/defaults/production.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ const config = {
log: {
format: 'custom',
pattern:
':id :email :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', // only for custom format
':requestId :id :email :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', // only for custom format
json: true,
level: 'info',
},
sentry: {
dsn: process.env.DEVKIT_NODE_sentry_dsn || '',
environment: 'production',
enabled: !!process.env.DEVKIT_NODE_sentry_dsn,
},
};

Expand Down
8 changes: 8 additions & 0 deletions config/defaults/test.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const config = {
uri: 'mongodb://127.0.0.1:27017/NodeTest',
debug: false,
},
audit: {
enabled: true,
ttlDays: 1,
},
sentry: {
dsn: '',
enabled: false,
},
organizations: {
enabled: false,
domainMatching: false,
Expand Down
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export default {
'!<rootDir>/modules/auth/auth.init.js',
// Exclude analytics init glue — just calls AnalyticsService.init()
'!<rootDir>/modules/analytics/analytics.init.js',
// Exclude audit init glue — just logs activation status
'!<rootDir>/modules/audit/audit.init.js',
// Exclude dead code — never imported anywhere in the codebase
'!<rootDir>/modules/users/services/users.data.service.js',
// Exclude server bootstrap — startup orchestration, tested indirectly via integration tests
Expand Down Expand Up @@ -177,7 +179,7 @@ export default {
// testLocationInResults: false,

// The glob patterns Jest uses to detect test files
testMatch: ['<rootDir>/modules/*/tests/**/*.js'],
testMatch: ['<rootDir>/modules/*/tests/**/*.js', '<rootDir>/lib/**/tests/**/*.js'],

// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/'],
Expand Down
3 changes: 3 additions & 0 deletions lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import express from './services/express.js';
import mongooseService from './services/mongoose.js';
import migrations from './services/migrations.js';
import AnalyticsService from '../modules/analytics/services/analytics.service.js';
import SentryService from './services/sentry.js';

// Establish an SQL server connection, instantiating all models and schemas
// const startSequelize = () =>
Expand Down Expand Up @@ -70,6 +71,7 @@ const bootstrap = async () => {
// }

try {
await SentryService.init();
db = await startMongoose();
await migrations.run();
app = await startExpress();
Expand Down Expand Up @@ -149,6 +151,7 @@ const shutdown = async (server) => {
try {
const value = await server;
await AnalyticsService.shutdown();
await SentryService.shutdown();
await mongooseService.disconnect();
value.http.close((err) => {
console.info(chalk.yellow('Server closed'));
Expand Down
1 change: 1 addition & 0 deletions lib/middlewares/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ const deriveSubjectType = (routePath) => {
if (routePath === '/api/billing/subscription') return 'BillingSubscription';
if (routePath === '/api/billing/usage') return 'BillingUsage';
if (routePath === '/api/billing/plans') return 'BillingPlans';
if (routePath.startsWith('/api/audit')) return 'AuditLog';
if (routePath.startsWith('/api/tasks')) return 'Task';
if (routePath.startsWith('/api/uploads')) return 'Upload';
if (routePath.startsWith('/api/home')) return 'Home';
Expand Down
32 changes: 32 additions & 0 deletions lib/middlewares/requestId.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Module dependencies
*/
import { randomUUID } from 'crypto';

/**
* Validate that a request ID is safe for use in logs and headers.
* Accepts alphanumeric characters, dashes, and underscores up to 128 chars.
* @param {string} id - Candidate request ID
* @returns {boolean} true if the ID is valid
*/
const isValidRequestId = (id) => typeof id === 'string' && id.length <= 128 && /^[\w-]+$/.test(id);

/**
* Express middleware that assigns a unique request ID to every incoming request.
* If the client sends a valid `X-Request-ID` header, that value is reused;
* otherwise a new UUID v4 is generated. The ID is exposed as `req.id` and
* echoed back via the `X-Request-ID` response header for end-to-end tracing.
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const requestId = (req, res, next) => {
const clientId = req.headers['x-request-id'];
const id = (clientId && isValidRequestId(clientId)) ? clientId : randomUUID();
req.id = id;
res.setHeader('X-Request-ID', id);
next();
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

export default requestId;
48 changes: 48 additions & 0 deletions lib/middlewares/tests/requestId.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Module dependencies.
*/
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import requestId from '../requestId.js';

describe('requestId middleware unit tests:', () => {
let req;
let res;
let next;

beforeEach(() => {
req = { headers: {} };
res = {
_headers: {},
setHeader(key, value) {
this._headers[key] = value;
},
};
next = jest.fn();
});

test('should generate a UUID when no X-Request-ID header is present', () => {
requestId(req, res, next);

expect(req.id).toBeDefined();
expect(typeof req.id).toBe('string');
// UUID v4 format
expect(req.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(res._headers['X-Request-ID']).toBe(req.id);
expect(next).toHaveBeenCalledTimes(1);
});

test('should reuse X-Request-ID from incoming request header', () => {
req.headers['x-request-id'] = 'custom-id-123';

requestId(req, res, next);

expect(req.id).toBe('custom-id-123');
expect(res._headers['X-Request-ID']).toBe('custom-id-123');
expect(next).toHaveBeenCalledTimes(1);
});

test('should call next exactly once', () => {
requestId(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
});
8 changes: 8 additions & 0 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import swaggerUi from 'swagger-ui-express';

import config from '../../config/index.js';
import logger from './logger.js';
import requestId from '../middlewares/requestId.js';
import sentry from './sentry.js';

/**
* Initialize Swagger
Expand Down Expand Up @@ -103,6 +105,8 @@ const initMiddleware = (app) => {
if (_.has(config, 'log.format') && process.env.NODE_ENV !== 'test') {
morgan.token('id', (req) => _.get(req, 'user.id') || 'Unknown id');
morgan.token('email', (req) => _.get(req, 'user.email') || 'Unknown email');
morgan.token('requestId', (req) => req.id || '-');
morgan.token('orgId', (req) => _.get(req, 'organization.id') || _.get(req, 'organization._id', '-'));
app.use(morgan(logger.getLogFormat(), logger.getMorganOptions()));
Comment thread
PierreBrisorgueil marked this conversation as resolved.
}
// Request body parsing middleware should be above methodOverride
Expand Down Expand Up @@ -208,6 +212,8 @@ const init = async () => {
initSwagger(app);
// Initialize local variables
initLocalVariables(app);
// Assign a unique request ID before any route registration
app.use(requestId);
// Initialize pre-parser routes (before body parsing and CSRF)
await initPreParserRoutes(app);
// Initialize Express middleware
Expand All @@ -224,6 +230,8 @@ const init = async () => {
await initModulesServerPolicies(app);
// Initialize modules server routes
await initModulesServerRoutes(app);
// Mount Sentry error handler (must be after routes, before generic error handler)
sentry.setupExpressErrorHandler(app);
// Initialize error routes
initErrorRoutes(app);
return app;
Expand Down
18 changes: 14 additions & 4 deletions lib/services/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@ import config from '../../config/index.js';
// list of valid formats for the logging
const validFormats = ['combined', 'common', 'dev', 'short', 'tiny', 'custom'];

// Determine log level from config (falls back to 'info')
const logLevel = _.get(config, 'log.level', 'info');

// Determine whether to use structured JSON output
const useJson = _.get(config, 'log.json', false);

// Build console transport format
const consoleFormat = useJson
? winston.format.combine(winston.format.timestamp(), winston.format.json())
: winston.format.combine(winston.format.colorize(), winston.format.simple());

// Instantiating the default winston application logger with the Console
// transport

/* eslint new-cap: 0 */
const logger = new winston.createLogger({
level: logLevel,
transports: [
new winston.transports.Console({
level: 'info',
colorize: true,
showLevel: true,
level: logLevel,
format: consoleFormat,
handleExceptions: true,
humanReadableUnhandledException: true,
}),
],
exitOnError: false,
Expand Down
76 changes: 76 additions & 0 deletions lib/services/sentry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Module dependencies
*/
import config from '../../config/index.js';

/**
* Sentry client reference (null when not configured).
* @type {import('@sentry/node')|null}
*/
let Sentry = null;

/**
* Initialise Sentry error tracking.
* When `sentry.dsn` is absent or `sentry.enabled` is false the service stays
* in no-op mode — every public method silently returns without side-effects.
*
* The `@sentry/node` SDK is lazy-loaded (dynamic import) so that
* applications that don't use Sentry are never affected.
* @returns {Promise<void>}
*/
const init = async () => {
const { dsn, environment, enabled } = config.sentry ?? {};
if (!dsn || enabled === false) return;

try {
const sentryModule = await import('@sentry/node');
Sentry = sentryModule.default || sentryModule;
Sentry.init({
dsn,
environment: environment || process.env.NODE_ENV || 'development',
// Adjust sample rates for production
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.
} catch (err) {
console.error('Sentry init failed:', err.message);
Sentry = null;
}
};

/**
* Set up the Sentry Express error handler.
* Must be called after all routes are mounted.
* @param {Object} app - Express application instance
*/
const setupExpressErrorHandler = (app) => {
if (!Sentry) return;
Sentry.setupExpressErrorHandler(app);
};

/**
* Capture an exception in Sentry.
* Safe to call even when Sentry is not configured.
* @param {Error} err - Error to capture
*/
const captureException = (err) => {
if (!Sentry) return;
Sentry.captureException(err);
};

/**
* Flush pending events and close the Sentry client.
* @param {number} [timeout=2000] - Timeout in ms
* @returns {Promise<void>}
*/
const shutdown = async (timeout = 2000) => {
if (!Sentry) return;
await Sentry.close(timeout);
Sentry = null;
};

export default {
init,
setupExpressErrorHandler,
captureException,
shutdown,
};
39 changes: 39 additions & 0 deletions lib/services/tests/sentry.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Module dependencies.
*/
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';

// Mock config — Sentry disabled by default
jest.unstable_mockModule('../../../config/index.js', () => ({
default: {
sentry: { dsn: '', environment: 'test', enabled: false },
},
Comment thread
PierreBrisorgueil marked this conversation as resolved.
}));

describe('SentryService unit tests:', () => {
let SentryService;

beforeEach(async () => {
const mod = await import('../sentry.js');
SentryService = mod.default;
});

afterEach(() => {
jest.restoreAllMocks();
});

test('should be a no-op when dsn is empty', async () => {
await SentryService.init();
// Should not throw
SentryService.captureException(new Error('test'));
SentryService.setupExpressErrorHandler({});
await SentryService.shutdown();
});

test('should export init, setupExpressErrorHandler, captureException, shutdown', () => {
expect(typeof SentryService.init).toBe('function');
expect(typeof SentryService.setupExpressErrorHandler).toBe('function');
expect(typeof SentryService.captureException).toBe('function');
expect(typeof SentryService.shutdown).toBe('function');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('Analytics service resilience tests:', () => {

const mod = await import('../services/analytics.service.js');
AnalyticsService = mod.default;
AnalyticsService.init();
await AnalyticsService.init();
});

afterEach(() => {
Expand Down
Loading
Loading