Skip to content

Commit 058bb94

Browse files
committed
feat: add logger options to file, separate http logs and app logs
1 parent a3bbaa9 commit 058bb94

9 files changed

Lines changed: 275 additions & 20 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Full-featured slim webserver for microservices with extremely low overhead and r
1616
## Requirements
1717

1818
```bash
19-
Node.js >= 14.8
19+
Node.js >= 22
2020
```
2121

2222
## Quick start
@@ -147,6 +147,23 @@ Default `false`
147147
148148
Enable request/response logging, format varies from `production` or `development` environments, though to change use e.g. `NODE_ENV=production`
149149
150+
#### `logger?` (Object)
151+
152+
Configure log outputs for app logs and http request logs.
153+
154+
```typescript
155+
const app = new BareHttp({
156+
logging: true,
157+
logger: {
158+
app: { file: './logs/app.log' },
159+
http: { file: './logs/http.log' },
160+
level: 'debug',
161+
},
162+
});
163+
```
164+
165+
`app.file` and `http.file` accept a path string or `{ path, sync }`, and `http.file` is used only when `logging: true`. `console` (default `true`) keeps stdout logging, and `pretty` (default `true` outside production) controls colored console output. `level` sets the base level and can be overridden per target with `app.level` or `http.level`. Use `sourceMaps: true` to map log locations back to `.ts` files (requires source maps in your build).
166+
150167
#### `ws?` (Boolean)
151168
152169
Default `false`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"lodash": "^4.17.21",
5454
"pino": "^10.1.0",
5555
"pino-pretty": "^13.1.3",
56+
"source-map-support": "^0.5.21",
5657
"ts-morph": "^27.0.2",
5758
"ws": "^8.18.3"
5859
},
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import axios from 'axios';
2+
3+
import { mkdtempSync, readFileSync, rmSync } from 'fs';
4+
import { tmpdir } from 'os';
5+
import { join } from 'path';
6+
7+
import { envs } from '../../env.js';
8+
import { BareHttp } from '../../server.js';
9+
import { logMe } from '../../logger/index.js';
10+
11+
test('Writes http and app logs into separate files', async () => {
12+
const tempDir = mkdtempSync(join(tmpdir(), 'barehttp-logger-'));
13+
const appLogPath = join(tempDir, 'app.log');
14+
const httpLogPath = join(tempDir, 'http.log');
15+
const prevIsTest = envs.isTest;
16+
17+
try {
18+
envs.isTest = false;
19+
const app = new BareHttp({
20+
setRandomPort: true,
21+
logging: true,
22+
logger: {
23+
console: false,
24+
pretty: false,
25+
app: { file: { path: appLogPath, sync: true } },
26+
http: { file: { path: httpLogPath, sync: true } },
27+
},
28+
});
29+
30+
app.route.get({
31+
route: '/logger-test',
32+
handler: () => {
33+
logMe.info('app-log-works');
34+
return { ok: true };
35+
},
36+
});
37+
38+
await app.start();
39+
await axios.get(`http://localhost:${app.getServerPort()}/logger-test`);
40+
await app.stop();
41+
42+
const appLogContents = readFileSync(appLogPath, 'utf8');
43+
const httpLogContents = readFileSync(httpLogPath, 'utf8');
44+
45+
expect(appLogContents).toContain('app-log-works');
46+
expect(httpLogContents).toContain('GET /logger-test');
47+
expect(appLogContents).not.toContain('GET /logger-test');
48+
expect(httpLogContents).not.toContain('app-log-works');
49+
} finally {
50+
envs.isTest = prevIsTest;
51+
rmSync(tempDir, { recursive: true, force: true });
52+
}
53+
});

src/examples/bare-http.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { BareHttp } from '../index.js';
1+
import { BareHttp, logMe } from '../index.js';
22

33
const app = new BareHttp({
4-
logging: false,
4+
logging: true,
5+
requestTimeFormat: 'ms',
6+
logger: {
7+
console: true,
8+
pretty: true,
9+
sourceMaps: true,
10+
level: 'trace',
11+
app: { file: './logs/example-app.log', level: 'trace' },
12+
http: { file: './logs/example-http.log', level: 'trace' },
13+
},
514
enableSchemaValidation: false,
615
declaredRoutesPaths: ['examples/bare-http'],
716
});
@@ -51,6 +60,7 @@ app.route.get({
5160
// if (flow.remoteIp) {
5261
// return { special: 'return' };
5362
// }
63+
logMe.info('Handling /route GET request');
5464
return returning;
5565
// flow.send(returning);
5666
},
@@ -105,6 +115,7 @@ app
105115
route: '/runtime_route',
106116
options: { timeout: 2000 },
107117
handler: async () => {
118+
logMe.info('Handling /runtime_route GET request');
108119
return 'JUST MESSAGE 2';
109120
},
110121
});

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export { BareHttp } from './server.js';
22
export type { BareHttpType } from './server.js';
33
export type { BareRequest } from './request.js';
44
export { context } from './context/index.js';
5-
export { logMe } from './logger/index.js';
5+
export { configureLogger, logMe } from './logger/index.js';
6+
export type { LoggerConfig } from './logger/index.js';

src/logger/index.ts

Lines changed: 176 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,184 @@
1-
import pino, { destination } from 'pino';
1+
import pino, {
2+
destination,
3+
type LevelWithSilent,
4+
type Logger as PinoLogger,
5+
type LoggerOptions as PinoLoggerOptions,
6+
} from 'pino';
27

8+
import { mkdirSync } from 'fs';
9+
import { dirname } from 'path';
310
import { serializeLog, serializeHttp } from './serializers.js';
4-
511
import { envs } from '../env.js';
612

7-
const asyncDest = envs.isProd ? [destination({ sync: false })] : [];
13+
type LoggerFileConfig = {
14+
path: string;
15+
sync?: boolean;
16+
};
817

9-
const pinoCommonOptions = {
18+
type LoggerTargetConfig = {
19+
file?: string | LoggerFileConfig;
20+
level?: LevelWithSilent;
21+
};
22+
23+
export type LoggerConfig = {
24+
console?: boolean;
25+
pretty?: boolean;
26+
level?: LevelWithSilent;
27+
app?: LoggerTargetConfig;
28+
http?: LoggerTargetConfig;
29+
sourceMaps?: boolean;
30+
};
31+
32+
const defaultPretty = !envs.isProd;
33+
const defaultFileSync = envs.isProd ? false : true;
34+
35+
const pinoCommonOptions: PinoLoggerOptions = {
1036
timestamp: () => `,"time":"${new Date()[envs.isProd ? 'toISOString' : 'toLocaleTimeString']()}"`,
1137
formatters: {
1238
level: (label: string) => ({ level: label }),
1339
},
1440
messageKey: 'message',
15-
transport: envs.isProd ? undefined : { target: 'pino-pretty', options: { colorize: true } },
1641
};
1742

18-
const logger = pino(pinoCommonOptions, asyncDest[0]);
43+
type LoggerLike = Record<LevelWithSilent, (...args: any[]) => void>;
44+
45+
const noop = () => {};
46+
const noopLogger: LoggerLike = {
47+
trace: noop,
48+
debug: noop,
49+
info: noop,
50+
warn: noop,
51+
error: noop,
52+
fatal: noop,
53+
silent: noop,
54+
};
55+
56+
let sourceMapsRegistered = false;
57+
58+
const enableSourceMaps = (enabled?: boolean) => {
59+
if (!enabled || sourceMapsRegistered) return;
60+
sourceMapsRegistered = true;
61+
void import('source-map-support/register').catch(() => {
62+
sourceMapsRegistered = false;
63+
});
64+
};
65+
66+
const normalizeFileConfig = (file?: string | LoggerFileConfig) => {
67+
if (!file) return undefined;
68+
if (typeof file === 'string') return { path: file, sync: defaultFileSync };
69+
return { path: file.path, sync: file.sync ?? defaultFileSync };
70+
};
71+
72+
const ensureLogDir = (filePath: string) => {
73+
const dir = dirname(filePath);
74+
if (dir && dir !== '.') {
75+
mkdirSync(dir, { recursive: true });
76+
}
77+
};
78+
79+
const toLoggerLike = (logger: PinoLogger): LoggerLike => {
80+
const call =
81+
(method: keyof LoggerLike) =>
82+
(...args: any[]) =>
83+
(logger as any)[method](...args);
84+
85+
return {
86+
trace: call('trace'),
87+
debug: call('debug'),
88+
info: call('info'),
89+
warn: call('warn'),
90+
error: call('error'),
91+
fatal: call('fatal'),
92+
silent: noop,
93+
};
94+
};
95+
96+
const buildConsoleLogger = (options: PinoLoggerOptions, prettyEnabled: boolean) => {
97+
if (prettyEnabled) {
98+
try {
99+
const transport = pino.transport({
100+
targets: [{ target: 'pino-pretty', options: { colorize: true } }],
101+
});
102+
return toLoggerLike(pino(options, transport));
103+
} catch {
104+
return toLoggerLike(pino(options, destination({ dest: 1, sync: false })));
105+
}
106+
}
107+
return toLoggerLike(pino(options, destination({ dest: 1, sync: false })));
108+
};
109+
110+
const buildFileLogger = (options: PinoLoggerOptions, fileConfig: LoggerFileConfig) => {
111+
ensureLogDir(fileConfig.path);
112+
return toLoggerLike(
113+
pino(options, destination({ dest: fileConfig.path, sync: fileConfig.sync ?? defaultFileSync })),
114+
);
115+
};
116+
117+
const combineLoggers = (primary?: LoggerLike, secondary?: LoggerLike): LoggerLike => {
118+
if (!primary && !secondary) return noopLogger;
119+
const first = primary ?? noopLogger;
120+
const second = secondary ?? noopLogger;
121+
return {
122+
trace: (...args) => {
123+
first.trace(...args);
124+
second.trace(...args);
125+
},
126+
debug: (...args) => {
127+
first.debug(...args);
128+
second.debug(...args);
129+
},
130+
info: (...args) => {
131+
first.info(...args);
132+
second.info(...args);
133+
},
134+
warn: (...args) => {
135+
first.warn(...args);
136+
second.warn(...args);
137+
},
138+
error: (...args) => {
139+
first.error(...args);
140+
second.error(...args);
141+
},
142+
fatal: (...args) => {
143+
first.fatal(...args);
144+
second.fatal(...args);
145+
},
146+
silent: noop,
147+
};
148+
};
149+
150+
const buildLogger = (config: LoggerConfig, target?: LoggerTargetConfig): LoggerLike => {
151+
const consoleEnabled = config.console ?? true;
152+
const prettyEnabled = config.pretty ?? defaultPretty;
153+
const fileConfig = normalizeFileConfig(target?.file);
154+
const level = target?.level ?? config.level;
155+
156+
const options: PinoLoggerOptions = { ...pinoCommonOptions };
157+
if (level) options.level = level;
158+
159+
const consoleLogger = consoleEnabled ? buildConsoleLogger(options, prettyEnabled) : undefined;
160+
const fileLogger = fileConfig ? buildFileLogger(options, fileConfig) : undefined;
161+
162+
return combineLoggers(consoleLogger, fileLogger);
163+
};
164+
165+
let appLogger: LoggerLike;
166+
let httpLogger: LoggerLike;
167+
168+
export const configureLogger = (config: LoggerConfig = {}) => {
169+
enableSourceMaps(config.sourceMaps);
170+
171+
if (config.app || config.http) {
172+
appLogger = buildLogger(config, config.app);
173+
httpLogger = buildLogger(config, config.http);
174+
} else {
175+
const shared = buildLogger(config);
176+
appLogger = shared;
177+
httpLogger = shared;
178+
}
179+
};
180+
181+
configureLogger();
19182

20183
interface LogMeFn {
21184
(obj: unknown, ...args: []): void;
@@ -33,15 +196,15 @@ type LogMe = {
33196

34197
export const logHttp = (...params: Parameters<typeof serializeHttp>) => {
35198
const { level, logObject } = serializeHttp(...params);
36-
logger[level](logObject);
199+
httpLogger[level](logObject);
37200
};
38201

39202
// TODO: remove the test condition
40203
export const logMe: LogMe = {
41-
debug: (...args) => !envs.isTest && logger.debug(serializeLog(...args)),
42-
info: (...args) => !envs.isTest && logger.info(serializeLog(...args)),
43-
warn: (...args) => !envs.isTest && logger.warn(serializeLog(...args)),
44-
error: (...args) => !envs.isTest && logger.error(serializeLog(...args)),
45-
fatal: (...args) => !envs.isTest && logger.fatal(serializeLog(...args)),
46-
trace: (...args) => !envs.isTest && logger.trace(serializeLog(...args)),
204+
debug: (...args) => !envs.isTest && appLogger.debug(serializeLog(...args)),
205+
info: (...args) => !envs.isTest && appLogger.info(serializeLog(...args)),
206+
warn: (...args) => !envs.isTest && appLogger.warn(serializeLog(...args)),
207+
error: (...args) => !envs.isTest && appLogger.error(serializeLog(...args)),
208+
fatal: (...args) => !envs.isTest && appLogger.fatal(serializeLog(...args)),
209+
trace: (...args) => !envs.isTest && appLogger.trace(serializeLog(...args)),
47210
};

src/server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { ServerOptions } from 'ws';
33
import { Ajv, ValidateFunction } from 'ajv';
44

55
import { BareRequest, CacheOpts } from './request.js';
6-
import { logMe } from './logger/index.js';
6+
import { configureLogger, logMe } from './logger/index.js';
7+
import type { LoggerConfig } from './logger/index.js';
78
import { context, enableContext, newContext } from './context/index.js';
89
import { CookiesManagerOptions } from './middlewares/cookies/cookie-manager.js';
910
import {
@@ -77,6 +78,10 @@ type BareOptions<A extends IP> = {
7778
* Default `false`
7879
*/
7980
logging?: boolean;
81+
/**
82+
* Logger configuration. Controls outputs for app and http logs.
83+
*/
84+
logger?: LoggerConfig;
8085
errorHandlerMiddleware?: ErrorHandler;
8186
/**
8287
* Request time format in `seconds` or `milliseconds`
@@ -213,6 +218,8 @@ export class BareServer<A extends IP> {
213218
private applyLaunchOptions = () => {
214219
const { bareOptions: bo } = this;
215220

221+
if (bo.logger) configureLogger(bo.logger);
222+
216223
if (bo.setRandomPort) {
217224
this.#port = undefined as any;
218225
} else {

tsconfig.dev.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"extends": "./tsconfig.json",
33
"compilerOptions": {
4-
"outDir": "./dev-lib"
4+
"outDir": "./dev-lib",
5+
"sourceMap": true,
6+
"inlineSources": true
57
},
68
"exclude": ["**/__tests__/**"]
79
}

0 commit comments

Comments
 (0)