Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/data/prebuilds/prebuild-logs-emitter.ts
2501 views
1
/**
2
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import { useEffect, useMemo } from "react";
8
import { matchPrebuildError } from "@gitpod/public-api-common/lib/prebuild-utils";
9
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
10
import { Disposable, DisposableCollection, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol";
11
import { Prebuild, PrebuildPhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb";
12
import { PlainMessage } from "@bufbuild/protobuf";
13
import { ReplayableEventEmitter } from "../../utils";
14
15
type LogEventTypes = {
16
error: [Error];
17
logs: [string];
18
"logs-error": [ApplicationError];
19
reset: [];
20
};
21
22
/**
23
* Watches the logs of a prebuild task by returning an EventEmitter that emits logs, logs-error, and error events.
24
* @param prebuildId ID of the prebuild to watch
25
* @param taskId ID of the task to watch.
26
*/
27
export function usePrebuildLogsEmitter(prebuild: PlainMessage<Prebuild>, taskId: string) {
28
const emitter = useMemo(
29
() => new ReplayableEventEmitter<LogEventTypes>(),
30
// We would like to re-create the emitter when the prebuildId or taskId changes, so that logs of old tasks / prebuilds are not mixed with the new ones.
31
// eslint-disable-next-line react-hooks/exhaustive-deps
32
[prebuild.id, taskId],
33
);
34
35
const shouldFetchLogs = useMemo<boolean>(() => {
36
const phase = prebuild.status?.phase?.name;
37
if (phase === PrebuildPhase_Phase.QUEUED && taskId === "image-build") {
38
return true;
39
}
40
switch (phase) {
41
case PrebuildPhase_Phase.QUEUED:
42
case PrebuildPhase_Phase.UNSPECIFIED:
43
return false;
44
// This is the online case: we do the actual streaming
45
// All others below are terminal states, where we get re-directed to the logs stored in content-service
46
case PrebuildPhase_Phase.BUILDING:
47
case PrebuildPhase_Phase.AVAILABLE:
48
case PrebuildPhase_Phase.FAILED:
49
case PrebuildPhase_Phase.ABORTED:
50
case PrebuildPhase_Phase.TIMEOUT:
51
return true;
52
}
53
54
return false;
55
}, [prebuild.status?.phase?.name, taskId]);
56
57
useEffect(() => {
58
if (!shouldFetchLogs || emitter.hasReachedEnd()) {
59
return;
60
}
61
62
const task = {
63
taskId,
64
logUrl: "",
65
};
66
if (taskId === "image-build") {
67
if (!prebuild.status?.imageBuildLogUrl) {
68
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Image build logs URL not found in response`);
69
}
70
task.logUrl = prebuild.status?.imageBuildLogUrl;
71
} else {
72
const logUrl = prebuild?.status?.taskLogs?.find((log) => log.taskId === taskId)?.logUrl;
73
if (!logUrl) {
74
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Task ${taskId} not found`);
75
}
76
77
task.logUrl = logUrl;
78
}
79
80
const disposables = new DisposableCollection();
81
disposables.push(
82
streamPrebuildLogs(
83
taskId,
84
task.logUrl,
85
(chunk) => {
86
emitter.emit("logs", chunk);
87
},
88
(err) => {
89
emitter.emit("logs-error", err);
90
},
91
() => {
92
emitter.markReachedEnd();
93
},
94
),
95
);
96
97
return () => {
98
disposables.dispose();
99
if (!emitter.hasReachedEnd()) {
100
// If we haven't finished yet, but the page is re-rendered, clear the output we already got.
101
emitter.emit("reset");
102
}
103
};
104
// eslint-disable-next-line react-hooks/exhaustive-deps
105
}, [emitter, prebuild.id, taskId, shouldFetchLogs]);
106
107
return { emitter };
108
}
109
110
function streamPrebuildLogs(
111
taskId: string,
112
streamUrl: string,
113
onLog: (chunk: Uint8Array) => void,
114
onError: (err: Error) => void,
115
onEnd?: () => void,
116
): DisposableCollection {
117
const disposables = new DisposableCollection();
118
119
// initializing non-empty here to use this as a stopping signal for the retries down below
120
disposables.push(Disposable.NULL);
121
122
// retry configuration goes here
123
const initialDelaySeconds = 1;
124
const backoffFactor = 1.2;
125
const maxBackoffSeconds = 5;
126
let delayInSeconds = initialDelaySeconds;
127
128
const startWatchingLogs = async () => {
129
const retryBackoff = async (reason: string, err?: Error) => {
130
delayInSeconds = Math.min(delayInSeconds * backoffFactor, maxBackoffSeconds);
131
132
console.debug("re-trying headless-logs because: " + reason, err);
133
await new Promise((resolve) => {
134
setTimeout(resolve, delayInSeconds * 1000);
135
});
136
if (disposables.disposed) {
137
return; // and stop retrying
138
}
139
startWatchingLogs().catch(console.error);
140
};
141
142
let response: Response | undefined = undefined;
143
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined = undefined;
144
try {
145
disposables.push({
146
dispose: async () => {
147
await reader?.cancel();
148
},
149
});
150
console.debug("fetching from streamUrl: " + streamUrl);
151
response = await fetch(streamUrl, {
152
method: "GET",
153
cache: "no-store", // we don't want the browser to a) look at the cache, or b) update the cache (which would interrupt any running fetches to that resource!)
154
credentials: "include",
155
headers: {
156
TE: "trailers", // necessary to receive stream status code
157
},
158
redirect: "follow",
159
});
160
reader = response.body?.getReader();
161
if (!reader) {
162
await retryBackoff("no reader");
163
return;
164
}
165
166
const decoder = new TextDecoder("utf-8");
167
let chunk = await reader.read();
168
let received200 = false;
169
while (!chunk.done) {
170
if (disposables.disposed) {
171
// stop reading when disposed
172
return;
173
}
174
175
// In an ideal world, we'd use res.addTrailers()/response.trailer here. But despite being introduced with HTTP/1.1 in 1999, trailers are not supported by popular proxies (nginx, for example).
176
// So we resort to this hand-written solution:
177
const msg = decoder.decode(chunk.value, { stream: true });
178
const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX);
179
const prebuildMatches = matchPrebuildError(msg);
180
if (matches) {
181
if (matches.length < 2) {
182
console.debug("error parsing log stream status code. msg: " + msg);
183
} else {
184
const prefix = msg.substring(0, matches.index);
185
if (prefix) {
186
const prefixChunk = new TextEncoder().encode(prefix);
187
onLog(prefixChunk);
188
}
189
const code = parseStatusCode(matches[1]);
190
if (code !== 200) {
191
throw new StreamError(code);
192
}
193
if (code === 200) {
194
received200 = true;
195
break;
196
}
197
}
198
} else if (prebuildMatches) {
199
if (prebuildMatches.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {
200
// reset backoff because this error is expected
201
delayInSeconds = initialDelaySeconds;
202
throw prebuildMatches;
203
}
204
onError(prebuildMatches);
205
} else {
206
onLog(chunk.value);
207
}
208
209
chunk = await reader.read();
210
}
211
console.info("[stream] end of stream", { received200 });
212
reader.cancel();
213
} catch (err) {
214
if (err instanceof DOMException && err.name === "AbortError") {
215
console.debug("stopped watching headless logs, not retrying: method got disposed of");
216
return;
217
}
218
reader?.cancel().catch(console.debug);
219
if (err.code === 400) {
220
// sth is really off, and we _should not_ retry
221
console.error("stopped watching headless logs", err);
222
return;
223
}
224
await retryBackoff("error while listening to stream", err);
225
} finally {
226
reader?.cancel().catch(console.debug);
227
if (onEnd) {
228
onEnd();
229
}
230
}
231
};
232
startWatchingLogs().catch(console.error);
233
234
return disposables;
235
}
236
237
class StreamError extends Error {
238
constructor(readonly code?: number) {
239
super(`stream status code: ${code}`);
240
}
241
}
242
243
function parseStatusCode(code: string | undefined): number | undefined {
244
try {
245
if (!code) {
246
return undefined;
247
}
248
return Number.parseInt(code);
249
} catch (err) {
250
return undefined;
251
}
252
}
253
254