-
Notifications
You must be signed in to change notification settings - Fork 48
Expand file tree
/
Copy pathlogBuffer.ts
More file actions
175 lines (155 loc) · 4.86 KB
/
Copy pathlogBuffer.ts
File metadata and controls
175 lines (155 loc) · 4.86 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
163
164
165
166
167
168
169
170
171
172
173
174
175
import { safeStringify } from "./utils";
import type { 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;
/** Replays buffered below-level log entries on a connection failure. */
export interface ConnectionLogBuffer {
flush(reason: string, options?: { readonly retain?: boolean }): void;
readonly onConnectionFailure: (reason: string, route: string) => void;
}
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 BufferingLogger implements Logger, ConnectionLogBuffer {
private entries: LogEntry[] = [];
private chars = 0;
public constructor(
private readonly inner: Logger,
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");
/**
* Flush the buffer on a terminal socket failure, keyed by the
* `<reason> <route>` string Support greps for. Arrow property so it can be
* passed by value as the socket's failure callback.
*/
public readonly onConnectionFailure = (
reason: string,
route: string,
): void => {
this.flush(`${reason} ${route}`);
};
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) => {
this.record(level, message, args);
this.inner[level](message, ...args);
};
}
/**
* 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 || SEVERITY[level] >= this.channel.logLevel) {
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;
}
}
}