@sentio/runtime 4.5.0-rc.1 → 4.5.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/processor-runner.js +146 -14
- package/dist/processor-runner.js.map +1 -1
- package/package.json +1 -1
- package/src/logger.ts +203 -2
package/package.json
CHANGED
package/src/logger.ts
CHANGED
|
@@ -13,7 +13,166 @@ function stringify(obj: any): string {
|
|
|
13
13
|
})
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Caps on what a processor may write through `console.*`.
|
|
18
|
+
*
|
|
19
|
+
* Every line the processor prints ends up as a container log line that the platform's log
|
|
20
|
+
* collector has to parse, buffer and ship; a handler that prints a multi-megabyte value on
|
|
21
|
+
* every invocation can emit tens of GB per hour and take the node-local collector down with it.
|
|
22
|
+
* These limits keep a single processor's output bounded. A value of 0 disables that limit.
|
|
23
|
+
*/
|
|
24
|
+
export interface LogGuardOptions {
|
|
25
|
+
/** Longest message (in UTF-8 bytes) a single line may carry; longer ones are cut with a notice. */
|
|
26
|
+
maxLineBytes: number
|
|
27
|
+
/** Lines admitted per wall-clock second; the rest of that second is dropped and summarised. */
|
|
28
|
+
maxLinesPerSecond: number
|
|
29
|
+
/** Message bytes admitted per wall-clock second; the rest of that second is dropped and summarised. */
|
|
30
|
+
maxBytesPerSecond: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_LOG_GUARD_OPTIONS: LogGuardOptions = {
|
|
34
|
+
maxLineBytes: 16 * 1024,
|
|
35
|
+
maxLinesPerSecond: 1000,
|
|
36
|
+
maxBytesPerSecond: 1024 * 1024
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const LOG_GUARD_ENV = {
|
|
40
|
+
maxLineBytes: 'SENTIO_LOG_MAX_LINE_BYTES',
|
|
41
|
+
maxLinesPerSecond: 'SENTIO_LOG_MAX_LINES_PER_SECOND',
|
|
42
|
+
maxBytesPerSecond: 'SENTIO_LOG_MAX_BYTES_PER_SECOND'
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
/** Reads the guard limits from the environment, falling back to the defaults for unset or invalid values. */
|
|
46
|
+
export function logGuardOptionsFromEnv(env: NodeJS.ProcessEnv = process.env): LogGuardOptions {
|
|
47
|
+
const read = (key: keyof LogGuardOptions): number => {
|
|
48
|
+
const raw = env[LOG_GUARD_ENV[key]]?.trim()
|
|
49
|
+
if (!raw) {
|
|
50
|
+
return DEFAULT_LOG_GUARD_OPTIONS[key]
|
|
51
|
+
}
|
|
52
|
+
const value = Number(raw)
|
|
53
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_LOG_GUARD_OPTIONS[key]
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
maxLineBytes: read('maxLineBytes'),
|
|
57
|
+
maxLinesPerSecond: read('maxLinesPerSecond'),
|
|
58
|
+
maxBytesPerSecond: read('maxBytesPerSecond')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const WINDOW_MS = 1000
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Per-process accounting behind {@link LogGuardOptions}: truncates oversized text and decides,
|
|
66
|
+
* per one-second window, whether another line still fits. Lines that do not fit are dropped;
|
|
67
|
+
* once the window ends a single summary line reports how many were lost, via `onSummary`.
|
|
68
|
+
*/
|
|
69
|
+
export class LogGuard {
|
|
70
|
+
private windowStart = -Infinity
|
|
71
|
+
private lines = 0
|
|
72
|
+
private bytes = 0
|
|
73
|
+
private droppedLines = 0
|
|
74
|
+
private droppedBytes = 0
|
|
75
|
+
private summaryTimer: NodeJS.Timeout | undefined
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly options: LogGuardOptions,
|
|
79
|
+
private readonly onSummary: (summary: string) => void,
|
|
80
|
+
private readonly now: () => number = Date.now
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
/** Returns `text` unchanged when it fits in `maxLineBytes`, otherwise its head plus a notice. */
|
|
84
|
+
truncate(text: string): string {
|
|
85
|
+
const max = this.options.maxLineBytes
|
|
86
|
+
// A UTF-16 code unit never encodes to more than 3 UTF-8 bytes, so short strings skip the byte count.
|
|
87
|
+
if (max <= 0 || text.length * 3 <= max) {
|
|
88
|
+
return text
|
|
89
|
+
}
|
|
90
|
+
const size = Buffer.byteLength(text)
|
|
91
|
+
if (size <= max) {
|
|
92
|
+
return text
|
|
93
|
+
}
|
|
94
|
+
// stream: true makes the decoder hold back a partially cut multi-byte character instead of
|
|
95
|
+
// emitting U+FFFD for it, so the cut lands on a character boundary.
|
|
96
|
+
const head = new TextDecoder().decode(Buffer.from(text).subarray(0, max), { stream: true })
|
|
97
|
+
return `${head} ...[truncated by sentio runtime: line was ${size} bytes, limit ${max}]`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Accounts a line of `size` bytes against the current window; `false` means the line must be dropped. */
|
|
101
|
+
admit(size: number): boolean {
|
|
102
|
+
const t = this.now()
|
|
103
|
+
if (t - this.windowStart >= WINDOW_MS) {
|
|
104
|
+
this.rollWindow(t)
|
|
105
|
+
}
|
|
106
|
+
const { maxLinesPerSecond, maxBytesPerSecond } = this.options
|
|
107
|
+
const overLines = maxLinesPerSecond > 0 && this.lines + 1 > maxLinesPerSecond
|
|
108
|
+
const overBytes = maxBytesPerSecond > 0 && this.bytes + size > maxBytesPerSecond
|
|
109
|
+
if (overLines || overBytes) {
|
|
110
|
+
this.droppedLines++
|
|
111
|
+
this.droppedBytes += size
|
|
112
|
+
this.scheduleSummary(this.windowStart + WINDOW_MS - t)
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
this.lines++
|
|
116
|
+
this.bytes += size
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Emits the pending drop summary, if any, without waiting for the window to end. */
|
|
121
|
+
flush(): void {
|
|
122
|
+
if (this.summaryTimer) {
|
|
123
|
+
clearTimeout(this.summaryTimer)
|
|
124
|
+
this.summaryTimer = undefined
|
|
125
|
+
}
|
|
126
|
+
if (this.droppedLines === 0) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const { droppedLines, droppedBytes } = this
|
|
130
|
+
this.droppedLines = 0
|
|
131
|
+
this.droppedBytes = 0
|
|
132
|
+
const { maxLinesPerSecond, maxBytesPerSecond } = this.options
|
|
133
|
+
this.onSummary(
|
|
134
|
+
`[sentio runtime] dropped ${droppedLines} console log lines (${droppedBytes} bytes) in the last second: ` +
|
|
135
|
+
`output rate limit exceeded (max ${maxLinesPerSecond} lines/s, ${maxBytesPerSecond} bytes/s)`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private rollWindow(t: number): void {
|
|
140
|
+
this.flush()
|
|
141
|
+
this.windowStart = t
|
|
142
|
+
this.lines = 0
|
|
143
|
+
this.bytes = 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private scheduleSummary(delayMs: number): void {
|
|
147
|
+
if (this.summaryTimer) {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
// The summary must also appear when the burst is followed by silence, so do not wait for the next
|
|
151
|
+
// line to roll the window. unref keeps the timer from holding the process open on shutdown.
|
|
152
|
+
this.summaryTimer = setTimeout(
|
|
153
|
+
() => {
|
|
154
|
+
this.summaryTimer = undefined
|
|
155
|
+
this.flush()
|
|
156
|
+
},
|
|
157
|
+
Math.max(delayMs, 0)
|
|
158
|
+
)
|
|
159
|
+
this.summaryTimer.unref?.()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const GUARD_BYPASS = Symbol.for('sentio.logGuardBypass')
|
|
164
|
+
const MESSAGE = Symbol.for('message')
|
|
165
|
+
|
|
166
|
+
// Fields the formats above set on `info` themselves. Everything else winston copied from a trailing
|
|
167
|
+
// object argument (`console.log('msg', { ... })`) and the json/simple formats serialise in full.
|
|
168
|
+
const OWN_INFO_FIELDS = new Set(['level', 'timestamp'])
|
|
169
|
+
|
|
170
|
+
export function setupLogger(
|
|
171
|
+
json: boolean,
|
|
172
|
+
enableDebug: boolean,
|
|
173
|
+
workerId?: number,
|
|
174
|
+
guardOptions: LogGuardOptions = logGuardOptionsFromEnv()
|
|
175
|
+
) {
|
|
17
176
|
const utilFormatter = {
|
|
18
177
|
transform: (info: any) => {
|
|
19
178
|
const stringRes = []
|
|
@@ -40,12 +199,54 @@ export function setupLogger(json: boolean, enableDebug: boolean, workerId?: numb
|
|
|
40
199
|
return info
|
|
41
200
|
}
|
|
42
201
|
}
|
|
202
|
+
|
|
203
|
+
const guard = new LogGuard(guardOptions, (summary) => {
|
|
204
|
+
logger.warn({ message: summary, [GUARD_BYPASS]: true } as any)
|
|
205
|
+
})
|
|
206
|
+
const render = json ? format.json() : format.simple()
|
|
207
|
+
// Runs on the rendered line, i.e. on exactly the bytes the transport would write, so nothing that
|
|
208
|
+
// winston copied onto `info` can slip past it. A line within the cap costs one byte count; an
|
|
209
|
+
// oversized one has its message and stack cut, every other field dropped, and is rendered again.
|
|
210
|
+
// Returning false discards the entry. The summary line bypasses the guard so it cannot be dropped
|
|
211
|
+
// by the very limit it reports; debug lines the level filter discards anyway are not charged.
|
|
212
|
+
const guardFormatter = {
|
|
213
|
+
transform: (info: any) => {
|
|
214
|
+
if (info[GUARD_BYPASS] || (info.level === 'debug' && !enableDebug)) {
|
|
215
|
+
return info
|
|
216
|
+
}
|
|
217
|
+
let size = Buffer.byteLength(String(info[MESSAGE]))
|
|
218
|
+
const max = guardOptions.maxLineBytes
|
|
219
|
+
if (max > 0 && size > max) {
|
|
220
|
+
let dropped = 0
|
|
221
|
+
for (const key of Object.keys(info)) {
|
|
222
|
+
const keepAsText = (key === 'message' || key === 'stack') && typeof info[key] === 'string'
|
|
223
|
+
if (!OWN_INFO_FIELDS.has(key) && !keepAsText) {
|
|
224
|
+
delete info[key]
|
|
225
|
+
dropped++
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
info.message = guard.truncate(String(info.message ?? ''))
|
|
229
|
+
if (typeof info.stack === 'string') {
|
|
230
|
+
info.stack = guard.truncate(info.stack)
|
|
231
|
+
}
|
|
232
|
+
if (dropped > 0) {
|
|
233
|
+
const note = `${dropped} metadata fields dropped by sentio runtime: line was ${size} bytes, limit ${max}`
|
|
234
|
+
info.message += ` ...[${note}]`
|
|
235
|
+
}
|
|
236
|
+
info = render.transform(info, render.options)
|
|
237
|
+
size = Buffer.byteLength(String(info[MESSAGE]))
|
|
238
|
+
}
|
|
239
|
+
return guard.admit(size) ? info : false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
43
243
|
const logger = createLogger({
|
|
44
244
|
format: format.combine(
|
|
45
245
|
format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
|
|
46
246
|
utilFormatter,
|
|
47
247
|
format.errors({ stack: true }),
|
|
48
|
-
|
|
248
|
+
render,
|
|
249
|
+
guardFormatter,
|
|
49
250
|
format.label({ label: workerId ? `worker #{workerId}` : '' })
|
|
50
251
|
),
|
|
51
252
|
level: enableDebug ? 'debug' : 'info',
|