Skip to content

Commit d47e440

Browse files
committed
Logging: Allowed other file formats to be passed through config
ref https://linear.app/ghost/issue/PRO-1533 - The filename is currently hardcoded which causes a large amount of randomness when the hostnames for configured Ghost instances change - This allows us to hardcode the filename to a specific format (eg `production.log`) since uniqueness is already handled elsewhere (eg the folder name)
1 parent 219507f commit d47e440

File tree

3 files changed

+121
-9
lines changed

3 files changed

+121
-9
lines changed

packages/logging/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ Follow the instructions for the top-level repo.
3636

3737
# Copyright & License
3838

39-
Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE).
39+
Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE).

packages/logging/lib/GhostLogger.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class GhostLogger {
2626
* transports: An array of comma separated transports (e.g. stdout, stderr, geld, loggly, file)
2727
* rotation: Enable or disable file rotation.
2828
* path: Path where to store log files.
29+
* filename: Optional filename template for log files. Supports {env} and {domain} placeholders.
30+
* If not provided, defaults to {domain}_{env} format.
2931
* loggly: Loggly transport configuration.
3032
* elasticsearch: Elasticsearch transport configuration
3133
* gelf: Gelf transport configuration.
@@ -45,6 +47,7 @@ class GhostLogger {
4547
this.logBody = options.logBody || false;
4648
this.mode = process.env.MODE || options.mode || 'short';
4749
this.path = options.path || process.cwd();
50+
this.filename = options.filename || '{domain}_{env}';
4851
this.loggly = options.loggly || {};
4952
this.elasticsearch = options.elasticsearch || {};
5053
this.gelf = options.gelf || {};
@@ -275,6 +278,30 @@ class GhostLogger {
275278
};
276279
}
277280

281+
/**
282+
* @description Sanitize domain for use in filenames.
283+
* Replaces all non-word characters with underscores.
284+
* @param {string} domain - The domain to sanitize
285+
* @returns {string} Sanitized domain safe for filenames
286+
* @example
287+
* sanitizeDomain('http://my-domain.com') // returns 'http___my_domain_com'
288+
*/
289+
sanitizeDomain(domain) {
290+
return domain.replace(/[^\w]/gi, '_');
291+
}
292+
293+
/**
294+
* @description Replace placeholders in filename template.
295+
* @param {string} template - Filename template with placeholders
296+
* @returns {string} Filename with placeholders replaced
297+
*/
298+
// TODO: Expand to other placeholders?
299+
replaceFilenamePlaceholders(template) {
300+
return template
301+
.replace(/{env}/g, this.env)
302+
.replace(/{domain}/g, this.sanitizeDomain(this.domain));
303+
}
304+
278305
/**
279306
* @description Setup file stream.
280307
*
@@ -283,8 +310,7 @@ class GhostLogger {
283310
* 2. file-all: everything
284311
*/
285312
setFileStream() {
286-
// e.g. http://my-domain.com --> http___my_domain_com
287-
const sanitizedDomain = this.domain.replace(/[^\w]/gi, '_');
313+
const baseFilename = this.replaceFilenamePlaceholders(this.filename);
288314

289315
// CASE: target log folder does not exist, show warning
290316
if (!fs.existsSync(this.path)) {
@@ -296,7 +322,7 @@ class GhostLogger {
296322
if (this.rotation.useLibrary) {
297323
const RotatingFileStream = require('@tryghost/bunyan-rotating-filestream');
298324
const rotationConfig = {
299-
path: `${this.path}${sanitizedDomain}_${this.env}.log`,
325+
path: `${this.path}${baseFilename}.log`,
300326
period: this.rotation.period,
301327
threshold: this.rotation.threshold,
302328
totalFiles: this.rotation.count,
@@ -310,7 +336,7 @@ class GhostLogger {
310336
name: this.name,
311337
streams: [{
312338
stream: new RotatingFileStream(Object.assign({}, rotationConfig, {
313-
path: `${this.path}${sanitizedDomain}_${this.env}.error.log`
339+
path: `${this.path}${baseFilename}.error.log`
314340
})),
315341
level: 'error'
316342
}],
@@ -337,7 +363,7 @@ class GhostLogger {
337363
name: this.name,
338364
streams: [{
339365
type: 'rotating-file',
340-
path: `${this.path}${sanitizedDomain}_${this.env}.error.log`,
366+
path: `${this.path}${baseFilename}.error.log`,
341367
period: this.rotation.period,
342368
count: this.rotation.count,
343369
level: 'error'
@@ -352,7 +378,7 @@ class GhostLogger {
352378
name: this.name,
353379
streams: [{
354380
type: 'rotating-file',
355-
path: `${this.path}${sanitizedDomain}_${this.env}.log`,
381+
path: `${this.path}${baseFilename}.log`,
356382
period: this.rotation.period,
357383
count: this.rotation.count,
358384
level: this.level
@@ -367,7 +393,7 @@ class GhostLogger {
367393
log: bunyan.createLogger({
368394
name: this.name,
369395
streams: [{
370-
path: `${this.path}${sanitizedDomain}_${this.env}.error.log`,
396+
path: `${this.path}${baseFilename}.error.log`,
371397
level: 'error'
372398
}],
373399
serializers: this.serializers
@@ -379,7 +405,7 @@ class GhostLogger {
379405
log: bunyan.createLogger({
380406
name: this.name,
381407
streams: [{
382-
path: `${this.path}${sanitizedDomain}_${this.env}.log`,
408+
path: `${this.path}${baseFilename}.log`,
383409
level: this.level
384410
}],
385411
serializers: this.serializers

packages/logging/test/logging.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,92 @@ describe('Logging', function () {
457457
});
458458
});
459459

460+
describe('filename computation', function () {
461+
it('sanitizeDomain should replace non-word characters with underscores', function () {
462+
var ghostLogger = new GhostLogger();
463+
ghostLogger.sanitizeDomain('http://my-domain.com').should.eql('http___my_domain_com');
464+
ghostLogger.sanitizeDomain('localhost').should.eql('localhost');
465+
ghostLogger.sanitizeDomain('example.com:8080').should.eql('example_com_8080');
466+
});
467+
468+
it('replaceFilenamePlaceholders should replace {env} placeholder', function () {
469+
var ghostLogger = new GhostLogger({env: 'production'});
470+
ghostLogger.replaceFilenamePlaceholders('{env}').should.eql('production');
471+
});
472+
473+
it('replaceFilenamePlaceholders should replace {domain} placeholder', function () {
474+
var ghostLogger = new GhostLogger({domain: 'http://example.com'});
475+
ghostLogger.replaceFilenamePlaceholders('{domain}').should.eql('http___example_com');
476+
});
477+
478+
it('replaceFilenamePlaceholders should replace both {env} and {domain} placeholders', function () {
479+
var ghostLogger = new GhostLogger({
480+
domain: 'http://example.com',
481+
env: 'staging'
482+
});
483+
ghostLogger.replaceFilenamePlaceholders('{domain}-{env}').should.eql('http___example_com-staging');
484+
ghostLogger.replaceFilenamePlaceholders('{env}.{domain}').should.eql('staging.http___example_com');
485+
});
486+
487+
it('logger should return default format when no filename option provided', function () {
488+
var ghostLogger = new GhostLogger({
489+
domain: 'http://example.com',
490+
env: 'production'
491+
});
492+
ghostLogger.filename.should.eql('{domain}_{env}');
493+
});
494+
495+
it('logger should use filename template when provided', function () {
496+
var ghostLogger = new GhostLogger({
497+
domain: 'http://example.com',
498+
env: 'production',
499+
filename: '{env}'
500+
});
501+
ghostLogger.filename.should.eql('{env}');
502+
});
503+
504+
it('file stream should use custom filename template', function () {
505+
const tempDir = './test-logs/';
506+
const rimraf = function (dir) {
507+
if (fs.existsSync(dir)) {
508+
fs.readdirSync(dir).forEach(function (file) {
509+
const curPath = dir + '/' + file;
510+
if (fs.lstatSync(curPath).isDirectory()) {
511+
rimraf(curPath);
512+
} else {
513+
fs.unlinkSync(curPath);
514+
}
515+
});
516+
fs.rmdirSync(dir);
517+
}
518+
};
519+
520+
// Create temp directory
521+
if (!fs.existsSync(tempDir)) {
522+
fs.mkdirSync(tempDir, {recursive: true});
523+
}
524+
525+
var ghostLogger = new GhostLogger({
526+
domain: 'test.com',
527+
env: 'production',
528+
filename: '{env}',
529+
transports: ['file'],
530+
path: tempDir
531+
});
532+
533+
ghostLogger.info('Test log message');
534+
535+
// Give it a moment to write
536+
setTimeout(function () {
537+
fs.existsSync(tempDir + 'production.log').should.eql(true);
538+
fs.existsSync(tempDir + 'production.error.log').should.eql(true);
539+
540+
// Cleanup
541+
rimraf(tempDir);
542+
}, 100);
543+
});
544+
});
545+
460546
describe('serialization', function () {
461547
it('serializes error into correct object', function (done) {
462548
const err = new errors.NotFoundError();

0 commit comments

Comments
 (0)