Repository navigation
Expand file tree
/
Copy pathflightRecorder.ts
More file actions
162 lines (144 loc) · 4.45 KB
/
Copy pathflightRecorder.ts
File metadata and controls
162 lines (144 loc) · 4.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import { redactSecrets, safeStringify } from "./formatters";
import type { LogSink, Logger } from "./logger";
/**
* Numeric severities matching `vscode.LogLevel` (Off=0, Trace=1, Debug=2,
* Info=3, Warning=4, Error=5). Kept as plain numbers so this module stays
* free of the VS Code API and easy to test.
*/
const SEVERITY = {
trace: 1,
debug: 2,
info: 3,
warn: 4,
error: 5,
} as const;
type Level = keyof typeof SEVERITY;
/** Sink methods that the output channel persists at any non-Off level. */
type ReplaySink = "info" | "warn" | "error";
/**
* Character budget for the buffered text, independent of the entry count. Bounds
* worst-case memory when `httpClientLogLevel: body` makes each entry large.
*/
const MAX_BUFFERED_CHARS = 2_000_000;
/** Entries replayed per channel call, so a flush is not one RPC per entry. */
const REPLAY_CHUNK = 100;
interface LogEntry {
readonly atMs: number;
readonly level: Level;
/** Message and args formatted once at record time; holds no live references. */
readonly text: string;
}
/**
* Buffers entries below the current log level and replays them on failure at a
* level the output channel persists.
*/
export class FlightRecorder implements Logger {
private entries: LogEntry[] = [];
private chars = 0;
public constructor(
private readonly inner: LogSink,
private readonly channel: { readonly logLevel: number },
private capacity: number,
) {}
public readonly trace = this.wrap("trace");
public readonly debug = this.wrap("debug");
public readonly info = this.wrap("info");
public readonly warn = this.wrap("warn");
public readonly error = this.wrap("error");
public show(): void {
this.inner.show();
}
/** Resize the ring, keeping the most recent entries within both budgets. */
public setCapacity(capacity: number): void {
this.capacity = capacity;
this.trim();
}
/**
* Replay buffered entries into the sink. No-op when empty. Clears the ring by
* default, so consecutive failures never replay the same entries twice; pass
* `retain` for a snapshot flush (a support bundle) that must not disturb a
* later failure flush.
*/
public flush(
reason: string,
options: { readonly retain?: boolean } = {},
): void {
// The channel writes nothing at Off. Keep the entries for the next flush;
// nothing new is recorded while Off.
if (this.channel.logLevel === 0) {
return;
}
if (this.entries.length === 0) {
return;
}
const entries = this.entries;
if (!options.retain) {
this.entries = [];
this.chars = 0;
}
const sink = this.replaySink();
this.inner[sink](
`[buffered] replaying ${entries.length} buffered entries (${reason})`,
);
const lines = entries.map((entry) =>
`[buffered] ${new Date(entry.atMs).toISOString()} ${entry.level.toUpperCase()} ${entry.text}`.replaceAll(
"\n",
"\n[buffered] ",
),
);
for (let i = 0; i < lines.length; i += REPLAY_CHUNK) {
this.inner[sink](lines.slice(i, i + REPLAY_CHUNK).join("\n"));
}
this.inner[sink](`[buffered] end of buffered logs (${reason})`);
}
/** Pass a call through to the sink and buffer it when below the level. */
private wrap(level: Level): (message: string, ...args: unknown[]) => void {
return (message, ...args) => {
if (SEVERITY[level] < this.channel.logLevel) {
// The channel drops this entry, so only the buffered copy is formatted.
this.record(level, message, args);
this.inner[level](message, ...args);
} else {
this.inner[level](message, ...args.map(redactSecrets));
}
};
}
/**
* The least-verbose sink method that is still written at the current level,
* so replayed entries are persisted rather than dropped again.
*/
private replaySink(): ReplaySink {
const level = this.channel.logLevel;
if (level >= SEVERITY.error) {
return "error";
}
if (level >= SEVERITY.warn) {
return "warn";
}
return "info";
}
private record(level: Level, message: string, args: unknown[]): void {
if (this.capacity === 0) {
return;
}
const text = [message, ...args.map((arg) => safeStringify(arg) ?? "")].join(
" ",
);
this.entries.push({ atMs: Date.now(), level, text });
this.chars += text.length;
this.trim();
}
/** Evict oldest entries until both the count and character budgets hold. */
private trim(): void {
while (
this.entries.length > this.capacity ||
this.chars > MAX_BUFFERED_CHARS
) {
const removed = this.entries.shift();
if (removed === undefined) {
break;
}
this.chars -= removed.text.length;
}
}
}