Skip to content

Commit 31df517

Browse files
authored
feat: Enhance request logging with custom morgan configuration
- Move morgan setup from app.js to config/morgan.js - Added requester IP to the morgan log output for non-production/test environments to assist with debugging when multiple developers are testing a running instance - Add the following to the request logs with custom tokens: - colored-status: Color-coded HTTP status - short-date: Cleaner timestamp format - parsed-user-agent: Simplified browser/OS info - content-length-in-kb: Human-readable sizes when the content isn't streamed - transfer-state: COMPLETE, PARTIAL, NO_RESPONSE to identify if the client disconnected before the end of the data transfer
1 parent 5b3a642 commit 31df517

File tree

6 files changed

+290
-7
lines changed

6 files changed

+290
-7
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ The metadata for Open Graph is only set up for the home page (`home.pug`). Updat
420420

421421
| Name | Description |
422422
| -------------------------------- | -------------------------------------------------------------------- |
423+
| **config**/morgan.js | Configuration for request logging with morgan. |
423424
| **config**/nodemailer.js | Configuration and helper function for sending email with nodemailer. |
424425
| **config**/passport.js | Passport Local and OAuth strategies, plus login middleware. |
425426
| **controllers**/api.js | Controller for /api route and all api examples. |
@@ -474,6 +475,7 @@ Required to run the project before your modifications
474475
| @popperjs/core | Frontend js library for poppers and tooltips. |
475476
| bootstrap | CSS Framework. |
476477
| bootstrap-social | Social buttons library. |
478+
| bowser | User agent parser |
477479
| chart.js | Frontend js library for creating charts. |
478480
| cheerio | Scrape web pages using jQuery-style syntax. |
479481
| compression | Node.js compression middleware. |

app.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const path = require('path');
55
const express = require('express');
66
const compression = require('compression');
77
const session = require('express-session');
8-
const logger = require('morgan');
98
const errorHandler = require('errorhandler');
109
const lusca = require('lusca');
1110
const dotenv = require('dotenv');
@@ -53,6 +52,11 @@ const contactController = require('./controllers/contact');
5352
*/
5453
const passportConfig = require('./config/passport');
5554

55+
/**
56+
* Request logging configuration
57+
*/
58+
const { morganLogger } = require('./config/morgan');
59+
5660
/**
5761
* Create Express server.
5862
*/
@@ -77,8 +81,8 @@ app.set('port', process.env.PORT || process.env.OPENSHIFT_NODEJS_PORT || 8080);
7781
app.set('views', path.join(__dirname, 'views'));
7882
app.set('view engine', 'pug');
7983
app.set('trust proxy', numberOfProxies);
84+
app.use(morganLogger());
8085
app.use(compression());
81-
app.use(logger('dev'));
8286
app.use(express.json());
8387
app.use(express.urlencoded({ extended: true }));
8488
app.use(limiter);

config/morgan.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
const logger = require('morgan');
2+
const Bowser = require('bowser');
3+
4+
// Color definitions for console output
5+
const colors = {
6+
red: '\x1b[31m',
7+
green: '\x1b[32m',
8+
yellow: '\x1b[33m',
9+
cyan: '\x1b[36m',
10+
reset: '\x1b[0m',
11+
};
12+
13+
// Custom colored status token
14+
logger.token('colored-status', (req, res) => {
15+
const status = res.statusCode;
16+
let color;
17+
if (status >= 500) color = colors.red;
18+
else if (status >= 400) color = colors.yellow;
19+
else if (status >= 300) color = colors.cyan;
20+
else color = colors.green;
21+
22+
return color + status + colors.reset;
23+
});
24+
25+
// Custom token for timestamp without timezone offset
26+
logger.token('short-date', () => {
27+
const now = new Date();
28+
return now.toLocaleString('sv').replace(',', '');
29+
});
30+
31+
// Custom token for simplified user agent using Bowser
32+
logger.token('parsed-user-agent', (req) => {
33+
const userAgent = req.headers['user-agent'];
34+
if (!userAgent) return 'Unknown';
35+
const parsedUA = Bowser.parse(userAgent);
36+
const osName = parsedUA.os.name || 'Unknown';
37+
const browserName = parsedUA.browser.name || 'Unknown';
38+
39+
// Get major version number
40+
const version = parsedUA.browser.version || '';
41+
const majorVersion = version.split('.')[0];
42+
43+
return `${osName}/${browserName} v${majorVersion}`;
44+
});
45+
46+
// Track bytes actually sent
47+
logger.token('bytes-sent', (req, res) => {
48+
// Check for original uncompressed size first
49+
let length =
50+
res.getHeader('X-Original-Content-Length') || // Some compression middlewares add this
51+
res.get('x-content-length') || // Alternative header
52+
res.getHeader('Content-Length');
53+
54+
// For static files
55+
if (!length && res.locals && res.locals.stat) {
56+
length = res.locals.stat.size;
57+
}
58+
59+
// For response bodies (API responses)
60+
if (!length && res._contentLength) {
61+
length = res._contentLength;
62+
}
63+
64+
// If we found a length, format it
65+
if (length && Number.isNaN(Number(length)) === false) {
66+
return `${(parseInt(length, 10) / 1024).toFixed(2)}KB`;
67+
}
68+
69+
// For chunked responses
70+
const transferEncoding = res.getHeader('Transfer-Encoding');
71+
if (transferEncoding === 'chunked') {
72+
return 'chunked';
73+
}
74+
75+
return '-';
76+
});
77+
78+
// Track partial response info
79+
logger.token('transfer-state', (req, res) => {
80+
if (!res._header) return 'NO_RESPONSE';
81+
if (res.finished) return 'COMPLETE';
82+
return 'PARTIAL';
83+
});
84+
85+
// Define the custom request log format
86+
// In development/test environments, include the full IP address in the logs to facilitate debugging,
87+
// especially when collaborating with other developers testing the running instance.
88+
// In production, omit the IP address to reduce the risk of leaking sensitive information and to support
89+
// compliance with GDPR and other privacy regulations.
90+
// Also using a function so we can test it in our unit tests.
91+
const getMorganFormat = () =>
92+
process.env.NODE_ENV === 'production' ? ':short-date :method :url :colored-status :response-time[0]ms :bytes-sent :transfer-state - :parsed-user-agent' : ':short-date :method :url :colored-status :response-time[0]ms :bytes-sent :transfer-state :remote-addr :parsed-user-agent';
93+
94+
// Set the format once at initialization for the actual middleware so we don't have to evaluate on each call
95+
const morganFormat = getMorganFormat();
96+
97+
// Create a middleware to capture original content length
98+
const captureContentLength = (req, res, next) => {
99+
const originalWrite = res.write;
100+
const originalEnd = res.end;
101+
let length = 0;
102+
103+
res.write = (...args) => {
104+
const [chunk] = args;
105+
if (chunk) {
106+
length += chunk.length;
107+
}
108+
return originalWrite.apply(res, args);
109+
};
110+
111+
res.end = (...args) => {
112+
const [chunk] = args;
113+
if (chunk) {
114+
length += chunk.length;
115+
}
116+
if (length > 0) {
117+
res._contentLength = length;
118+
}
119+
return originalEnd.apply(res, args);
120+
};
121+
122+
next();
123+
};
124+
125+
exports.morganLogger = () => (req, res, next) => {
126+
captureContentLength(req, res, () => {
127+
logger(morganFormat, {
128+
immediate: false,
129+
})(req, res, next);
130+
});
131+
};
132+
133+
// Expose for testing
134+
exports._getMorganFormat = getMorganFormat;

package-lock.json

Lines changed: 11 additions & 4 deletions
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
@@ -32,6 +32,7 @@
3232
"@popperjs/core": "^2.11.8",
3333
"bootstrap": "^5.3.5",
3434
"bootstrap-social": "github:SeattleDevs/bootstrap-social",
35+
"bowser": "^2.11.0",
3536
"chart.js": "^4.4.8",
3637
"cheerio": "^1.0.0",
3738
"compression": "^1.8.0",
@@ -51,7 +52,7 @@
5152
"mongoose": "^8.13.2",
5253
"morgan": "^1.10.0",
5354
"multer": "^1.4.5-lts.1",
54-
"nodemailer": "^6.10.0",
55+
"nodemailer": "^6.10.1",
5556
"oauth": "^0.10.2",
5657
"passport": "^0.7.0",
5758
"passport-facebook": "^3.0.0",

test/morgan.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
const morgan = require('morgan');
2+
const { expect } = require('chai');
3+
const sinon = require('sinon');
4+
5+
// Import the morgan configuration to ensure tokens are registered
6+
const { _getMorganFormat } = require('../config/morgan');
7+
8+
describe('Morgan Configuration Tests', () => {
9+
let req;
10+
let res;
11+
let clock;
12+
const originalEnv = process.env.NODE_ENV;
13+
14+
beforeEach(() => {
15+
// Mock request
16+
req = {
17+
method: 'GET',
18+
url: '/test',
19+
headers: {
20+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
21+
'remote-addr': '127.0.0.1',
22+
},
23+
ip: '127.0.0.1',
24+
};
25+
26+
// Enhanced mock response
27+
res = {
28+
statusCode: 200,
29+
_header: true, // Set to true to indicate headers sent
30+
finished: true, // Set to true to indicate response complete
31+
_headers: {}, // for storing headers
32+
getHeader(name) {
33+
return this._headers[name.toLowerCase()];
34+
},
35+
get(name) {
36+
return this.getHeader(name);
37+
},
38+
setHeader(name, value) {
39+
this._headers[name.toLowerCase()] = value;
40+
},
41+
};
42+
43+
// Fix the date for consistent testing
44+
clock = sinon.useFakeTimers(new Date('2024-01-01T12:00:00').getTime());
45+
});
46+
47+
afterEach(() => {
48+
clock.restore();
49+
sinon.restore();
50+
process.env.NODE_ENV = originalEnv;
51+
});
52+
53+
describe('Custom Token: colored-status', () => {
54+
it('should color status codes correctly', () => {
55+
const testCases = [
56+
{ status: 200, color: '\x1b[32m' }, // green
57+
{ status: 304, color: '\x1b[36m' }, // cyan
58+
{ status: 404, color: '\x1b[33m' }, // yellow
59+
{ status: 500, color: '\x1b[31m' }, // red
60+
];
61+
62+
testCases.forEach(({ status, color }) => {
63+
res.statusCode = status;
64+
const formatter = morgan.compile(':colored-status');
65+
const output = formatter(morgan, req, res);
66+
expect(output).to.equal(`${color}${status}\x1b[0m`);
67+
});
68+
});
69+
});
70+
71+
describe('Custom Token: short-date', () => {
72+
it('should format date correctly', () => {
73+
const formatter = morgan.compile(':short-date');
74+
const output = formatter(morgan, req, res);
75+
expect(output).to.equal('2024-01-01 12:00:00');
76+
});
77+
});
78+
79+
describe('Custom Token: parsed-user-agent', () => {
80+
it('should parse user agent correctly', () => {
81+
const formatter = morgan.compile(':parsed-user-agent');
82+
const output = formatter(morgan, req, res);
83+
expect(output).to.equal('Windows/Chrome v120');
84+
});
85+
86+
it('should handle unknown user agent', () => {
87+
req.headers['user-agent'] = undefined;
88+
const formatter = morgan.compile(':parsed-user-agent');
89+
const output = formatter(morgan, req, res);
90+
expect(output).to.equal('Unknown');
91+
});
92+
});
93+
94+
describe('Custom Token: bytes-sent', () => {
95+
it('should format bytes correctly', () => {
96+
res.setHeader('Content-Length', '2048');
97+
const formatter = morgan.compile(':bytes-sent');
98+
const output = formatter(morgan, req, res);
99+
expect(output).to.equal('2.00KB');
100+
});
101+
102+
it('should handle missing content length', () => {
103+
const formatter = morgan.compile(':bytes-sent');
104+
const output = formatter(morgan, req, res);
105+
expect(output).to.equal('-');
106+
});
107+
});
108+
109+
describe('Custom Token: transfer-state', () => {
110+
it('should show correct transfer state', () => {
111+
const formatter = morgan.compile(':transfer-state');
112+
const output = formatter(morgan, req, res);
113+
expect(output).to.equal('COMPLETE');
114+
});
115+
});
116+
117+
describe('Complete Morgan Format', () => {
118+
// const { _getMorganFormat } = require('../config/morgan');
119+
120+
it('should combine all tokens correctly in development', () => {
121+
process.env.NODE_ENV = 'development';
122+
const formatter = morgan.compile(_getMorganFormat());
123+
const output = formatter(morgan, req, res);
124+
expect(output).to.include('127.0.0.1'); // Should include IP in development
125+
});
126+
127+
it('should exclude IP address in production', () => {
128+
process.env.NODE_ENV = 'production';
129+
const formatter = morgan.compile(_getMorganFormat());
130+
const output = formatter(morgan, req, res);
131+
expect(output).to.not.include('127.0.0.1'); // Should not include IP
132+
expect(output).to.include(' - '); // Should have hyphen instead
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)