Skip to content

Commit ef6d280

Browse files
committed
Add event limits
1 parent d34f992 commit ef6d280

File tree

7 files changed

+216
-6
lines changed

7 files changed

+216
-6
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ lead to conflicts between libraries. Libraries should use the
5151
[`-r log-process-errors` CLI flag](#usage-preferred) instead of
5252
[`require()`](#usage-custom).
5353

54-
# Duplicate warnings
54+
# Duplicate events
55+
56+
Duplicate events are only logged once.
5557

5658
It is recommended to use the
5759
[`--no-warnings` CLI flag](https://nodejs.org/api/cli.html#cli_no_warnings) or

package-lock.json

Lines changed: 26 additions & 1 deletion
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
@@ -43,7 +43,8 @@
4343
},
4444
"dependencies": {
4545
"chalk": "^2.4.1",
46-
"jest-validate": "^23.6.0"
46+
"jest-validate": "^23.6.0",
47+
"moize": "^5.4.1"
4748
},
4849
"devDependencies": {
4950
"eslint": "^5.7.0",

src/handle.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use strict'
22

33
const { getInfo } = require('./info')
4+
const { isLimited } = require('./limit')
5+
const { isRepeated } = require('./repeat')
46
const { getColors } = require('./colors')
57
const { getLevel } = require('./level')
68
const { getMessage } = require('./message')
@@ -10,12 +12,17 @@ const { exitProcess } = require('./exit')
1012
const handleEvent = async function({
1113
opts,
1214
eventName,
15+
previousEvents,
1316
error,
1417
promise,
1518
promiseValue,
1619
secondPromiseState,
1720
secondPromiseValue,
1821
}) {
22+
if (isLimited({ previousEvents, eventName, error })) {
23+
return
24+
}
25+
1926
const info = await getInfo({
2027
eventName,
2128
error,
@@ -25,10 +32,20 @@ const handleEvent = async function({
2532
secondPromiseValue,
2633
})
2734

35+
if (isRepeated({ info, previousEvents })) {
36+
return
37+
}
38+
2839
if (opts.skipEvent(info)) {
2940
return
3041
}
3142

43+
await logEvent({ opts, info })
44+
45+
await exitProcess({ eventName, opts })
46+
}
47+
48+
const logEvent = async function({ opts, info }) {
3249
const colors = getColors({ opts })
3350
const level = getLevel({ opts, info })
3451
const message = getMessage({ opts, info, level, colors })
@@ -39,8 +56,6 @@ const handleEvent = async function({
3956
// But there are some cases where it will not. In those cases, `opts.log()`
4057
// should be either synchronous or return a promise.
4158
await opts.log(message, level, info)
42-
43-
await exitProcess({ eventName, opts })
4459
}
4560

4661
module.exports = {

src/limit.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use strict'
2+
3+
const { emitWarning } = require('process')
4+
5+
const moize = require('moize').default
6+
7+
// We only allow 100 events (per `eventName`) for the global process because:
8+
// - process errors are exceptional and if more than 100 happen, this is
9+
// probably due to some infinite recursion.
10+
// - the `repeated` logic should prevent reaching the threshold
11+
// - `previousEvents` might otherwise take too much memory and/or create a
12+
// memory leak.
13+
// - it prevents infinite recursions if
14+
// `opts.log|getLevel|getMessage|skipEvent()` triggers itself an event.
15+
// The `repeated` logic should prevent it most of the times, but it can still
16+
// happen when `error` or `[second]promiseValue` is not an `Error` instance
17+
// and contain dynamic content.
18+
const isLimited = function({ previousEvents, eventName, error }) {
19+
if (isLimitedWarning({ eventName, error })) {
20+
return
21+
}
22+
23+
const isLimitedEvent = [...previousEvents].length >= MAX_EVENTS
24+
25+
if (isLimitedEvent) {
26+
mEmitLimitedWarning(eventName)
27+
}
28+
29+
return isLimitedEvent
30+
}
31+
32+
const MAX_EVENTS = 1e2
33+
34+
// Notify that limit has been reached with a `warning` event
35+
const emitLimitedWarning = function(eventName) {
36+
emitWarning(ERROR_MESSAGE(eventName), ERROR_NAME, ERROR_CODE)
37+
}
38+
39+
// The `warning` itself should not be skipped
40+
const isLimitedWarning = function({ eventName, error: { name, code } = {} }) {
41+
return eventName === 'warning' && name === ERROR_NAME && code === ERROR_CODE
42+
}
43+
44+
const ERROR_MESSAGE = eventName =>
45+
`Cannot log more than ${MAX_EVENTS} '${eventName}' until process is restarted`
46+
const ERROR_NAME = 'LogProcessError'
47+
const ERROR_CODE = 'TooManyErrors'
48+
49+
// Should only emit the warning once per `eventName`
50+
const mEmitLimitedWarning = moize(emitLimitedWarning)
51+
52+
module.exports = {
53+
isLimited,
54+
}

src/repeat.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use strict'
2+
3+
const { inspect } = require('util')
4+
5+
// Events with the same `info` are only logged once because:
6+
// - it makes logs clearer
7+
// - it prevents creating too much CPU load or too many microtasks
8+
// - it prevents creating too many logs, which can be expensive if logs are
9+
// hosted remotely
10+
// - it prevents infinite recursions if
11+
// `opts.log|getLevel|getMessage|skipEvent()` triggers itself an event
12+
// (while still reporting that event once)
13+
const isRepeated = function({ info, previousEvents }) {
14+
const fingerprint = getFingerprint({ info })
15+
16+
const isRepeatedEvent = previousEvents.has(fingerprint)
17+
18+
if (!isRepeatedEvent) {
19+
previousEvents.add(fingerprint)
20+
}
21+
22+
return isRepeatedEvent
23+
}
24+
25+
// Serialize `info` into a short fingerprint
26+
const getFingerprint = function({ info }) {
27+
const entries = INFO_PROPS.map(propName => serializeEntry({ info, propName }))
28+
const infoA = Object.assign({}, ...entries)
29+
30+
const fingerprint = JSON.stringify(infoA)
31+
32+
// We truncate fingerprints to prevent consuming too much memory in case some
33+
// `info` properties are huge.
34+
// This introduces higher risk of false positives (see comment below).
35+
// We do not hash as it would be too CPU-intensive if the value is huge.
36+
const fingerprintA = fingerprint.slice(0, FINGERPRINT_MAX_LENGTH)
37+
return fingerprintA
38+
}
39+
40+
// We do not serialize `info.eventName` since this is already `eventName-wise`
41+
// Key order matters since fingerprint might be truncated: we serialize short
42+
// and non-dynamic values first.
43+
const INFO_PROPS = [
44+
'secondPromiseState',
45+
'promiseState',
46+
'error',
47+
'secondPromiseValue',
48+
'promiseValue',
49+
]
50+
51+
const FINGERPRINT_MAX_LENGTH = 1e4
52+
53+
const serializeEntry = function({ info, propName }) {
54+
const value = info[propName]
55+
56+
if (value === undefined) {
57+
return
58+
}
59+
60+
const valueA = serializeValue({ value })
61+
return { [propName]: valueA }
62+
}
63+
64+
const serializeValue = function({ value }) {
65+
if (value instanceof Error) {
66+
return serializeError({ error: value })
67+
}
68+
69+
// We use `util.inspect()` instead of `JSON.stringify()` to support more
70+
// types and circular references.
71+
// `sorted` prevents the same event using different keys order from having
72+
// a different fingerprint. It is only `Node.js 10` but is backward-compatible
73+
// Big arrays, objects or buffers will be truncated, which makes this call
74+
// less CPU-intensive and the result value smaller in memory. However it
75+
// introduces higher risk of false positives (event being flagged as repeated
76+
// even though it's different). Process errors should be exceptional, so this
77+
// is ok.
78+
return inspect(value, { sorted: true })
79+
}
80+
81+
// We do not serialize `error.message` as it may contain dynamic values like
82+
// timestamps. This means errors are only `error.name` + `error.stack`, which
83+
// should be a good fingerprint.
84+
// Also we only keep first 10 callsites in case of infinitely recursive stack.
85+
const serializeError = function({ error: { name, stack } }) {
86+
const stackA = filterErrorStack({ stack })
87+
return `${name}\n${stackA}`
88+
}
89+
90+
const filterErrorStack = function({ stack }) {
91+
return stack
92+
.split('\n')
93+
.filter(line => STACK_TRACE_LINE_REGEXP.test(line))
94+
.slice(0, STACK_TRACE_MAX_LENGTH)
95+
.join('\n')
96+
}
97+
98+
const STACK_TRACE_LINE_REGEXP = /^\s+at /u
99+
const STACK_TRACE_MAX_LENGTH = 10
100+
101+
module.exports = {
102+
isRepeated,
103+
}

src/setup.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,17 @@ const addListeners = function({ opts }) {
2121
}
2222

2323
const addListener = function({ opts, eventName, eventFunc }) {
24-
const eventListener = eventFunc.bind(null, { opts, eventName })
24+
// `previousEvents` is `eventName`-specific so that if events of a given
25+
// `eventName` stopped being emitted, others still are.
26+
// `previousEvents` can take up some memory, but it should be cleaned up
27+
// by `removeListener()`, i.e. once `eventListener` is garbage collected.
28+
const previousEvents = new Set()
29+
30+
const eventListener = eventFunc.bind(null, {
31+
opts,
32+
eventName,
33+
previousEvents,
34+
})
2535
process.on(eventName, eventListener)
2636

2737
return { eventListener, eventName }

0 commit comments

Comments
 (0)