Skip to content

Commit 3b4bec6

Browse files
committed
feat(core): Improve logging: messages, levels, & formatting. Closes #9
1 parent 00e5f6c commit 3b4bec6

File tree

13 files changed

+219
-194
lines changed

13 files changed

+219
-194
lines changed

TODO.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
# TODO
22

3-
- Create Github issues for everything in this document
4-
- use labels for features/bugs/tests/docs/website/community/upstream
5-
- logger
6-
- remove the one logger.debug() call
7-
- replace starting/stopping calls to logger.info() with logger.debug()
8-
- use logger.info() when
9-
- restarting crashed service
10-
- received shutdown signal
113
- improve formatting of output; introduce "$composite" stream prefix
124
- ready helpers should be included as 2nd argument in call to `ready` config (adding the imports is annoying)
13-
- default `ready` should be `(ctx, helpers) => helpers.onceTimeout(1000)` ?
14-
- default `onCrash` should be `(ctx, helpers) => !ctx.isServiceReady && helpers.propagateCrash()`
5+
- default `onCrash` should be like `(ctx, helpers) => !ctx.isServiceReady && helpers.propagateCrash()`
156

7+
`gracefulShutdown` option should be `sequentialShutdown` instead
168
---
179

1810
- bugs

examples/shutdown.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
const { startCompositeService } = require('..')
22

3+
const logLevel = process.argv.includes('--log-level')
4+
? process.argv[process.argv.indexOf('--log-level') + 1]
5+
: undefined
36
const hang = process.argv.includes('--hang')
47

58
const command = [
@@ -19,14 +22,17 @@ process.on('SIGINT', () => {
1922
]
2023

2124
startCompositeService({
25+
logLevel,
2226
gracefulShutdown: true,
2327
windowsCtrlCShutdown: true,
2428
serviceDefaults: {
2529
command,
2630
forceKillTimeout: 2000,
2731
},
2832
services: {
29-
foo: {},
30-
bar: { dependencies: ['foo'] },
33+
one: {},
34+
two: { dependencies: ['one'] },
35+
three: { dependencies: ['one'] },
36+
four: { dependencies: ['two', 'three'] },
3137
},
3238
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"document-api": "node scripts/document-api.js"
4646
},
4747
"dependencies": {
48+
"chalk": "^4.1.0",
4849
"cloneable-readable": "^2.0.1",
4950
"express": "^4.17.1",
5051
"http-proxy-middleware": "^1.0.5",

src/core/CompositeService.ts

Lines changed: 40 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Service } from './Service'
2-
import serializeJavascript from 'serialize-javascript'
2+
import serializeJs from 'serialize-javascript'
33
import mergeStream from 'merge-stream'
4+
import chalk from 'chalk'
45
import {
56
NormalizedCompositeServiceConfig,
67
validateAndNormalizeConfig,
@@ -28,52 +29,41 @@ export class CompositeService {
2829

2930
this.logger = new Logger(this.config.logLevel)
3031
outputStream.add(this.logger.output)
31-
this.logger.debug(
32-
'Config: ' +
33-
serializeJavascript(config, {
34-
space: 2,
35-
unsafe: true,
36-
}),
37-
)
3832

39-
process.on('SIGINT', () => this.die(130, "Received 'SIGINT' signal"))
40-
process.on('SIGTERM', () => this.die(143, "Received 'SIGTERM' signal"))
33+
const configText = serializeJs(config, { space: 2, unsafe: true })
34+
this.logger.log('debug', `Config: ${configText}`)
35+
36+
process.on('SIGINT', () => this.handleShutdownSignal(130, 'SIGINT'))
37+
process.on('SIGTERM', () => this.handleShutdownSignal(143, 'SIGTERM'))
4138
if (process.stdin.isTTY) {
4239
process.stdin.setRawMode(true)
4340
process.stdin.on('data', buffer => {
4441
if (buffer.toString('utf8') === '\u0003') {
45-
this.die(130, 'Received ctrl+c')
42+
this.handleShutdownSignal(130, 'ctrl+c')
4643
}
4744
})
4845
}
4946

5047
this.services = Object.entries(this.config.services).map(
5148
([id, config]) =>
52-
new Service(id, config, this.logger, message =>
53-
this.handleError(`Error in '${id}': ${message}`),
54-
),
49+
new Service(id, config, this.logger, this.handleFatalError.bind(this)),
5550
)
5651
this.serviceMap = new Map(
5752
this.services.map(service => [service.id, service]),
5853
)
5954

60-
const maxLabelLength = Math.max(
61-
...Object.keys(this.config.services).map(({ length }) => length),
62-
)
6355
outputStream.add(
64-
this.services.map(({ output, id }) =>
65-
output.pipe(
66-
mapStreamLines(
67-
line => `${`${rightPad(id, maxLabelLength)} | `}${line}`,
68-
),
69-
),
70-
),
56+
this.services.map(({ output, id }, i) => {
57+
// luminosity of 20 because at 256 colors, luminosity from 16 to 24 yields the most colors (12 colors) while keeping high contrast with text
58+
const label = chalk.bgHsl((i / this.services.length) * 360, 100, 20)(id)
59+
return output.pipe(mapStreamLines(line => `${label} | ${line}`))
60+
}),
7161
)
7262

73-
this.logger.info('Starting composite service...')
63+
this.logger.log('debug', 'Starting composite service...')
7464
Promise.all(
7565
this.services.map(service => this.startService(service)),
76-
).then(() => this.logger.info('Started composite service'))
66+
).then(() => this.logger.log('debug', 'Started composite service'))
7767
}
7868

7969
private async startService(service: Service) {
@@ -90,29 +80,35 @@ export class CompositeService {
9080
}
9181
}
9282

93-
private handleError(message: string) {
94-
return this.die(1, message)
83+
private handleFatalError(message: string): void {
84+
this.logger.log('error', `Fatal error: ${message}`)
85+
if (!this.stopping) {
86+
this.stop(1)
87+
}
9588
}
9689

97-
private async die(exitCode: number, message: string): Promise<never> {
90+
private handleShutdownSignal(exitCode: number, description: string): void {
9891
if (!this.stopping) {
99-
this.stopping = true
100-
const isSignalExit = exitCode > 128 // we have either a signal exit or an error exit
101-
this.logger[isSignalExit ? 'info' : 'error'](message)
102-
this.logger.info('Stopping composite service...')
103-
if (this.config.windowsCtrlCShutdown) {
104-
require('generate-ctrl-c-event')
105-
.generateCtrlCAsync()
106-
.catch((error: Error) => this.logger.error(String(error)))
107-
}
108-
await Promise.all(this.services.map(service => this.stopService(service)))
109-
this.logger.info('Stopped composite service')
110-
await Promise.resolve() // Wait one micro tick for output to flush
111-
process.exit(exitCode)
92+
this.logger.log('info', `Received shutdown signal (${description})`)
93+
this.stop(exitCode)
11294
}
113-
// simply return a promise that never resolves, since we can't do anything after exiting anyways
114-
return never()
11595
}
96+
97+
private stop(exitCode: number): void {
98+
if (this.stopping) return
99+
this.stopping = true
100+
this.logger.log('debug', 'Stopping composite service...')
101+
if (this.config.windowsCtrlCShutdown) {
102+
require('generate-ctrl-c-event')
103+
.generateCtrlCAsync()
104+
.catch((error: Error) => this.logger.log('error', String(error)))
105+
}
106+
Promise.all(this.services.map(service => this.stopService(service)))
107+
.then(() => this.logger.log('debug', 'Stopped composite service'))
108+
// Wait one micro tick for output to flush
109+
.then(() => process.exit(exitCode))
110+
}
111+
116112
private async stopService(service: Service) {
117113
if (this.config.gracefulShutdown) {
118114
const dependents = this.services.filter(({ config }) =>
@@ -124,11 +120,6 @@ export class CompositeService {
124120
}
125121
}
126122

127-
function rightPad(string: string, length: number): string {
128-
// we assume length >= string.length
129-
return string + ' '.repeat(length - string.length)
130-
}
131-
132123
function never(): Promise<never> {
133124
return new Promise<never>(() => {})
134125
}

src/core/Logger.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
import { PassThrough } from 'stream'
2+
import chalk from 'chalk'
23

34
export type LogLevel = 'debug' | 'info' | 'error'
45

5-
export const orderedLogLevels: LogLevel[] = ['error', 'info', 'debug']
6+
const orderedLogLevels: LogLevel[] = ['error', 'info', 'debug']
7+
const logLevelColors = { error: 'red', info: 'teal', debug: 'yellow' }
68

79
export class Logger {
810
private level: LogLevel
9-
public readonly error: (text: string) => void
10-
public readonly info: (text: string) => void
11-
public readonly debug: (text: string) => void
1211
public readonly output = new PassThrough({ objectMode: true })
1312
constructor(level: LogLevel) {
1413
this.level = level
15-
this.error = this.log.bind(this, 'error')
16-
this.info = this.log.bind(this, 'info')
17-
this.debug = this.log.bind(this, 'debug')
1814
}
19-
private log(level: LogLevel, text: string) {
15+
public log(level: LogLevel, text: string) {
2016
if (this.shouldLog(level)) {
17+
const label = chalk.keyword(logLevelColors[level])(`(${level})`)
2118
for (const line of text.split('\n')) {
22-
this.output.write(`${level}: ${line}\n`)
19+
this.output.write(` ${label} ${line}\n`)
2320
}
2421
}
2522
}

src/core/Service.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export class Service {
1717
public readonly output = cloneable(new PassThrough({ objectMode: true }))
1818
private readonly outputClone = this.output.clone()
1919
private readonly logger: Logger
20-
private readonly handleError: (message: string) => Promise<never>
20+
private readonly handleFatalError: (message: string) => void
2121
private ready: Promise<void> | undefined
2222
private process: ServiceProcess | undefined
2323
private startResult: Promise<void> | undefined
@@ -28,26 +28,29 @@ export class Service {
2828
id: string,
2929
config: NormalizedServiceConfig,
3030
logger: Logger,
31-
handleError: (message: string) => Promise<never>,
31+
handleFatalError: (message: string) => void,
3232
) {
3333
this.id = id
3434
this.config = config
3535
this.logger = logger
36-
this.handleError = handleError
36+
this.handleFatalError = handleFatalError
3737
}
3838

3939
public start() {
4040
if (this.stopResult) {
41-
this.logger.error(new InternalError('Cannot start after stopping').stack!)
41+
this.logger.log(
42+
'error',
43+
new InternalError('Cannot start after stopping').stack!,
44+
)
4245
return this.startResult
4346
}
4447
if (!this.startResult) {
45-
this.logger.info(`Starting service '${this.id}'...`)
48+
this.logger.log('debug', `Starting service '${this.id}'...`)
4649
this.defineReady()
4750
this.startResult = this.startProcess()
4851
.then(() => this.ready)
4952
.then(() => {
50-
this.logger.info(`Started service '${this.id}'`)
53+
this.logger.log('debug', `Started service '${this.id}'`)
5154
})
5255
}
5356
return this.startResult
@@ -58,11 +61,11 @@ export class Service {
5861
output: this.outputClone,
5962
}
6063
this.ready = promiseTry(() => this.config.ready(ctx))
61-
.catch(error =>
62-
this.handleError(`Error from ready function: ${maybeErrorText(error)}`),
63-
)
64-
.then(() => {
65-
this.outputClone.destroy()
64+
.finally(() => this.outputClone.destroy())
65+
.catch(error => {
66+
const prefix = `In \`ready\` function for service '${this.id}'`
67+
this.handleFatalError(`${prefix}: ${maybeErrorText(error)}`)
68+
return never()
6669
})
6770
}
6871

@@ -78,12 +81,14 @@ export class Service {
7881
try {
7982
await this.process.started
8083
} catch (error) {
81-
await this.handleError(`Error starting process: ${error}`)
84+
const prefix = `Spawning process for service '${this.id}'`
85+
this.handleFatalError(`${prefix}: ${error}`)
86+
await never()
8287
}
8388
}
8489

8590
private async handleCrash(proc: ServiceProcess) {
86-
this.logger.info(`Service '${this.id}' crashed`)
91+
this.logger.log('info', `Service '${this.id}' crashed`)
8792
const delayPromise = delay(this.config.minimumRestartDelay)
8893
const crash: ServiceCrash = {
8994
date: new Date(),
@@ -99,9 +104,9 @@ export class Service {
99104
try {
100105
await this.config.onCrash(ctx)
101106
} catch (error) {
102-
await this.handleError(
103-
`Error from onCrash function: ${maybeErrorText(error)}`,
104-
)
107+
const prefix = `In \`onCrash\` function for service ${this.id}`
108+
this.handleFatalError(`${prefix}: ${maybeErrorText(error)}`)
109+
await never()
105110
}
106111
if (this.stopResult) {
107112
return
@@ -110,7 +115,7 @@ export class Service {
110115
if (this.stopResult) {
111116
return
112117
}
113-
this.logger.info(`Restarting service '${this.id}'`)
118+
this.logger.log('info', `Restarting service '${this.id}'`)
114119
await this.startProcess()
115120
}
116121

@@ -119,9 +124,9 @@ export class Service {
119124
if (!this.process || !this.process.isRunning()) {
120125
this.stopResult = Promise.resolve()
121126
} else {
122-
this.logger.info(`Stopping service '${this.id}'...`)
127+
this.logger.log('debug', `Stopping service '${this.id}'...`)
123128
this.stopResult = this.process.end(windowsCtrlCShutdown).then(() => {
124-
this.logger.info(`Stopped service '${this.id}'`)
129+
this.logger.log('debug', `Stopped service '${this.id}'`)
125130
})
126131
}
127132
}
@@ -150,3 +155,7 @@ function isResolved(promise: Promise<any>): Promise<boolean> {
150155
Promise.resolve().then(() => false),
151156
])
152157
}
158+
159+
function never(): Promise<never> {
160+
return new Promise<never>(() => {})
161+
}

src/core/ServiceProcess.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,10 @@ export class ServiceProcess {
7171
this.wasEndCalled = true
7272
if (this.isRunning()) {
7373
if (windowsCtrlCShutdown) {
74-
// Don't call this.process.kill(); ctrl+c was already sent to all services
74+
// ctrl+c signal was already sent to all service processes
7575
this.forceKillAfterTimeout()
7676
} else if (process.platform === 'win32') {
7777
this.process.kill()
78-
// Don't call this.forceKillAfterTimeout(); On Windows we don't have SIGINT vs SIGKILL
7978
} else {
8079
this.process.kill('SIGINT')
8180
this.forceKillAfterTimeout()
@@ -90,7 +89,7 @@ export class ServiceProcess {
9089
}
9190
setTimeout(() => {
9291
if (this.isRunning()) {
93-
this.logger.info(`Force killing service '${this.serviceId}'`)
92+
this.logger.log('info', `Force killing service '${this.serviceId}'`)
9493
this.process.kill('SIGKILL')
9594
}
9695
}, this.serviceConfig.forceKillTimeout)

src/core/startCompositeService.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ let started = false
1111
* Each service defined in {@link CompositeServiceConfig.services}
1212
* is started and managed according to its {@link ServiceConfig}.
1313
*
14+
* TODO: Move everything below to `docs/` & replace with "See Intro for more detailed specs".
15+
*
1416
* The stdout & stderr from each service is piped to stdout,
1517
* each line prepended with the service ID.
1618
*
19+
* TODO: Improve documentation below regarding shutting down & fatal errors
20+
*
1721
* Each service is told to shut down
1822
* when the composite service is itself told to shut down
1923
* (with `ctrl+c`, `SIGINT`, or `SIGTERM`),

0 commit comments

Comments
 (0)