Skip to content

Commit 65d2c1f

Browse files
feat(client): improve logging (#40)
1 parent 56c63d2 commit 65d2c1f

File tree

8 files changed

+378
-113
lines changed

8 files changed

+378
-113
lines changed

README.md

+54-22
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,59 @@ console.log(raw.headers.get('X-My-Header'));
187187
console.log(response.organizationId);
188188
```
189189

190+
### Logging
191+
192+
> [!IMPORTANT]
193+
> All log messages are intended for debugging only. The format and content of log messages
194+
> may change between releases.
195+
196+
#### Log levels
197+
198+
The log level can be configured in two ways:
199+
200+
1. Via the `GITPOD_LOG` environment variable
201+
2. Using the `logLevel` client option (overrides the environment variable if set)
202+
203+
```ts
204+
import Gitpod from '@gitpod/sdk';
205+
206+
const client = new Gitpod({
207+
logLevel: 'debug', // Show all log messages
208+
});
209+
```
210+
211+
Available log levels, from most to least verbose:
212+
213+
- `'debug'` - Show debug messages, info, warnings, and errors
214+
- `'info'` - Show info messages, warnings, and errors
215+
- `'warn'` - Show warnings and errors (default)
216+
- `'error'` - Show only errors
217+
- `'off'` - Disable all logging
218+
219+
At the `'debug'` level, all HTTP requests and responses are logged, including headers and bodies.
220+
Some authentication-related headers are redacted, but sensitive data in request and response bodies
221+
may still be visible.
222+
223+
#### Custom logger
224+
225+
By default, this library logs to `globalThis.console`. You can also provide a custom logger.
226+
Most logging libraries are supported, including [pino](https://www.npmjs.com/package/pino), [winston](https://www.npmjs.com/package/winston), [bunyan](https://www.npmjs.com/package/bunyan), [consola](https://www.npmjs.com/package/consola), [signale](https://www.npmjs.com/package/signale), and [@std/log](https://jsr.io/@std/log). If your logger doesn't work, please open an issue.
227+
228+
When providing a custom logger, the `logLevel` option still controls which messages are emitted, messages
229+
below the configured level will not be sent to your logger.
230+
231+
```ts
232+
import Gitpod from '@gitpod/sdk';
233+
import pino from 'pino';
234+
235+
const logger = pino();
236+
237+
const client = new Gitpod({
238+
logger: logger.child({ name: 'Gitpod' }),
239+
logLevel: 'debug', // Send all messages to pino, allowing it to filter
240+
});
241+
```
242+
190243
### Making custom/undocumented requests
191244

192245
This library is typed for convenient access to the documented API. If you need to access undocumented
@@ -246,33 +299,12 @@ globalThis.fetch = fetch;
246299
Or pass it to the client:
247300

248301
```ts
302+
import Gitpod from '@gitpod/sdk';
249303
import fetch from 'my-fetch';
250304

251305
const client = new Gitpod({ fetch });
252306
```
253307

254-
### Logging and middleware
255-
256-
You may also provide a custom `fetch` function when instantiating the client,
257-
which can be used to inspect or alter the `Request` or `Response` before/after each request:
258-
259-
```ts
260-
import { fetch } from 'undici'; // as one example
261-
import Gitpod from '@gitpod/sdk';
262-
263-
const client = new Gitpod({
264-
fetch: async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
265-
console.log('About to make a request', url, init);
266-
const response = await fetch(url, init);
267-
console.log('Got response', response);
268-
return response;
269-
},
270-
});
271-
```
272-
273-
Note that if given a `GITPOD_LOG=debug` environment variable, this library will log all requests and responses automatically.
274-
This is intended for debugging purposes only and may change in the future without notice.
275-
276308
### Fetch options
277309

278310
If you want to set custom `fetch` options without overriding the `fetch` function, you can provide a `fetchOptions` object when instantiating the client or making a request. (Request-specific options override client options.)

src/client.ts

+135-37
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types';
44
import type { HTTPMethod, PromiseOrValue, MergedRequestInit } from './internal/types';
55
import { uuid4 } from './internal/utils/uuid';
6-
import { validatePositiveInteger, isAbsoluteURL } from './internal/utils/values';
6+
import { validatePositiveInteger, isAbsoluteURL, hasOwn } from './internal/utils/values';
77
import { sleep } from './internal/utils/sleep';
88
import { castToError, isAbortError } from './internal/errors';
99
import type { APIResponseProps } from './internal/parse';
@@ -122,7 +122,7 @@ import {
122122
SecretsSecretsPage,
123123
} from './resources/secrets';
124124
import { readEnv } from './internal/utils/env';
125-
import { logger } from './internal/utils/log';
125+
import { formatRequestDetails, loggerFor } from './internal/utils/log';
126126
import { isEmptyObj } from './internal/utils/values';
127127
import {
128128
AdmissionLevel,
@@ -251,15 +251,30 @@ export type Logger = {
251251
debug: LogFn;
252252
};
253253
export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug';
254-
const isLogLevel = (key: string | undefined): key is LogLevel => {
254+
const parseLogLevel = (
255+
maybeLevel: string | undefined,
256+
sourceName: string,
257+
client: Gitpod,
258+
): LogLevel | undefined => {
259+
if (!maybeLevel) {
260+
return undefined;
261+
}
255262
const levels: Record<LogLevel, true> = {
256263
off: true,
257264
error: true,
258265
warn: true,
259266
info: true,
260267
debug: true,
261268
};
262-
return key! in levels;
269+
if (hasOwn(levels, maybeLevel)) {
270+
return maybeLevel;
271+
}
272+
loggerFor(client).warn(
273+
`${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify(
274+
Object.keys(levels),
275+
)}`,
276+
);
277+
return undefined;
263278
};
264279

265280
export interface ClientOptions {
@@ -323,16 +338,16 @@ export interface ClientOptions {
323338
/**
324339
* Set the log level.
325340
*
326-
* Defaults to process.env['GITPOD_LOG'].
341+
* Defaults to process.env['GITPOD_LOG'] or 'warn' if it isn't set.
327342
*/
328-
logLevel?: LogLevel | undefined | null;
343+
logLevel?: LogLevel | undefined;
329344

330345
/**
331346
* Set the logger.
332347
*
333348
* Defaults to globalThis.console.
334349
*/
335-
logger?: Logger | undefined | null;
350+
logger?: Logger | undefined;
336351
}
337352

338353
type FinalizedRequestInit = RequestInit & { headers: Headers };
@@ -387,14 +402,13 @@ export class Gitpod {
387402
this.baseURL = options.baseURL!;
388403
this.timeout = options.timeout ?? Gitpod.DEFAULT_TIMEOUT /* 1 minute */;
389404
this.logger = options.logger ?? console;
390-
if (options.logLevel != null) {
391-
this.logLevel = options.logLevel;
392-
} else {
393-
const envLevel = readEnv('GITPOD_LOG');
394-
if (isLogLevel(envLevel)) {
395-
this.logLevel = envLevel;
396-
}
397-
}
405+
const defaultLogLevel = 'warn';
406+
// Set default logLevel early so that we can log a warning in parseLogLevel.
407+
this.logLevel = defaultLogLevel;
408+
this.logLevel =
409+
parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ??
410+
parseLogLevel(readEnv('GITPOD_LOG'), "process.env['GITPOD_LOG']", this) ??
411+
defaultLogLevel;
398412
this.fetchOptions = options.fetchOptions;
399413
this.maxRetries = options.maxRetries ?? 2;
400414
this.fetch = options.fetch ?? Shims.getDefaultFetch();
@@ -524,12 +538,13 @@ export class Gitpod {
524538
options: PromiseOrValue<FinalRequestOptions>,
525539
remainingRetries: number | null = null,
526540
): APIPromise<Rsp> {
527-
return new APIPromise(this, this.makeRequest(options, remainingRetries));
541+
return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined));
528542
}
529543

530544
private async makeRequest(
531545
optionsInput: PromiseOrValue<FinalRequestOptions>,
532546
retriesRemaining: number | null,
547+
retryOfRequestLogID: string | undefined,
533548
): Promise<APIResponseProps> {
534549
const options = await optionsInput;
535550
const maxRetries = options.maxRetries ?? this.maxRetries;
@@ -543,60 +558,142 @@ export class Gitpod {
543558

544559
await this.prepareRequest(req, { url, options });
545560

546-
logger(this).debug('request', url, options, req.headers);
561+
/** Not an API request ID, just for correlating local log entries. */
562+
const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0');
563+
const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`;
564+
const startTime = Date.now();
565+
566+
loggerFor(this).debug(
567+
`[${requestLogID}] sending request`,
568+
formatRequestDetails({
569+
retryOfRequestLogID,
570+
method: options.method,
571+
url,
572+
options,
573+
headers: req.headers,
574+
}),
575+
);
547576

548577
if (options.signal?.aborted) {
549578
throw new Errors.APIUserAbortError();
550579
}
551580

552581
const controller = new AbortController();
553582
const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError);
583+
const headersTime = Date.now();
554584

555585
if (response instanceof Error) {
586+
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
556587
if (options.signal?.aborted) {
557588
throw new Errors.APIUserAbortError();
558589
}
559-
if (retriesRemaining) {
560-
return this.retryRequest(options, retriesRemaining);
561-
}
562-
if (isAbortError(response)) {
563-
throw new Errors.APIConnectionTimeoutError();
564-
}
565590
// detect native connection timeout errors
566591
// deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)"
567592
// undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)"
568593
// others do not provide enough information to distinguish timeouts from other connection errors
569-
if (/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''))) {
594+
const isTimeout =
595+
isAbortError(response) ||
596+
/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''));
597+
if (retriesRemaining) {
598+
loggerFor(this).info(
599+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`,
600+
);
601+
loggerFor(this).debug(
602+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`,
603+
formatRequestDetails({
604+
retryOfRequestLogID,
605+
url,
606+
durationMs: headersTime - startTime,
607+
message: response.message,
608+
}),
609+
);
610+
return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID);
611+
}
612+
loggerFor(this).info(
613+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`,
614+
);
615+
loggerFor(this).debug(
616+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`,
617+
formatRequestDetails({
618+
retryOfRequestLogID,
619+
url,
620+
durationMs: headersTime - startTime,
621+
message: response.message,
622+
}),
623+
);
624+
if (isTimeout) {
570625
throw new Errors.APIConnectionTimeoutError();
571626
}
572627
throw new Errors.APIConnectionError({ cause: response });
573628
}
574629

630+
const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${
631+
response.ok ? 'succeeded' : 'failed'
632+
} with status ${response.status} in ${headersTime - startTime}ms`;
633+
575634
if (!response.ok) {
576-
if (retriesRemaining && this.shouldRetry(response)) {
635+
const shouldRetry = this.shouldRetry(response);
636+
if (retriesRemaining && shouldRetry) {
577637
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
578-
logger(this).debug(`response (error; ${retryMessage})`, response.status, url, response.headers);
579-
return this.retryRequest(options, retriesRemaining, response.headers);
638+
639+
// We don't need the body of this response.
640+
await Shims.CancelReadableStream(response.body);
641+
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
642+
loggerFor(this).debug(
643+
`[${requestLogID}] response error (${retryMessage})`,
644+
formatRequestDetails({
645+
retryOfRequestLogID,
646+
url: response.url,
647+
status: response.status,
648+
headers: response.headers,
649+
durationMs: headersTime - startTime,
650+
}),
651+
);
652+
return this.retryRequest(
653+
options,
654+
retriesRemaining,
655+
retryOfRequestLogID ?? requestLogID,
656+
response.headers,
657+
);
580658
}
581659

660+
const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`;
661+
662+
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
663+
582664
const errText = await response.text().catch((err: any) => castToError(err).message);
583665
const errJSON = safeJSON(errText);
584666
const errMessage = errJSON ? undefined : errText;
585-
const retryMessage = retriesRemaining ? `(error; no more retries left)` : `(error; not retryable)`;
586667

587-
logger(this).debug(
588-
`response (error; ${retryMessage})`,
589-
response.status,
590-
url,
591-
response.headers,
592-
errMessage,
668+
loggerFor(this).debug(
669+
`[${requestLogID}] response error (${retryMessage})`,
670+
formatRequestDetails({
671+
retryOfRequestLogID,
672+
url: response.url,
673+
status: response.status,
674+
headers: response.headers,
675+
message: errMessage,
676+
durationMs: Date.now() - startTime,
677+
}),
593678
);
594679

595680
const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers);
596681
throw err;
597682
}
598683

599-
return { response, options, controller };
684+
loggerFor(this).info(responseInfo);
685+
loggerFor(this).debug(
686+
`[${requestLogID}] response start`,
687+
formatRequestDetails({
688+
retryOfRequestLogID,
689+
url: response.url,
690+
status: response.status,
691+
headers: response.headers,
692+
durationMs: headersTime - startTime,
693+
}),
694+
);
695+
696+
return { response, options, controller, requestLogID, retryOfRequestLogID, startTime };
600697
}
601698

602699
getAPIList<Item, PageClass extends Pagination.AbstractPage<Item> = Pagination.AbstractPage<Item>>(
@@ -614,7 +711,7 @@ export class Gitpod {
614711
Page: new (...args: ConstructorParameters<typeof Pagination.AbstractPage>) => PageClass,
615712
options: FinalRequestOptions,
616713
): Pagination.PagePromise<PageClass, Item> {
617-
const request = this.makeRequest(options, null);
714+
const request = this.makeRequest(options, null, undefined);
618715
return new Pagination.PagePromise<PageClass, Item>(this as any as Gitpod, request, Page);
619716
}
620717

@@ -677,6 +774,7 @@ export class Gitpod {
677774
private async retryRequest(
678775
options: FinalRequestOptions,
679776
retriesRemaining: number,
777+
requestLogID: string,
680778
responseHeaders?: Headers | undefined,
681779
): Promise<APIResponseProps> {
682780
let timeoutMillis: number | undefined;
@@ -709,7 +807,7 @@ export class Gitpod {
709807
}
710808
await sleep(timeoutMillis);
711809

712-
return this.makeRequest(options, retriesRemaining - 1);
810+
return this.makeRequest(options, retriesRemaining - 1, requestLogID);
713811
}
714812

715813
private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number {

0 commit comments

Comments
 (0)