@sentio/runtime 4.5.0-rc.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentio/runtime",
3
- "version": "4.5.0-rc.2",
3
+ "version": "4.5.0-rc.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/src/logger.ts CHANGED
@@ -13,7 +13,166 @@ function stringify(obj: any): string {
13
13
  })
14
14
  }
15
15
 
16
- export function setupLogger(json: boolean, enableDebug: boolean, workerId?: number) {
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
- json ? format.json() : format.simple(),
248
+ render,
249
+ guardFormatter,
49
250
  format.label({ label: workerId ? `worker #{workerId}` : '' })
50
251
  ),
51
252
  level: enableDebug ? 'debug' : 'info',