Skip to content

Commit 4818115

Browse files
feat(client): improve logging (#20)
1 parent 7f7be64 commit 4818115

File tree

8 files changed

+377
-112
lines changed

8 files changed

+377
-112
lines changed

Diff for: README.md

+54-22
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,59 @@ console.log(raw.headers.get('X-My-Header'));
149149
console.log(projects);
150150
```
151151

152+
### Logging
153+
154+
> [!IMPORTANT]
155+
> All log messages are intended for debugging only. The format and content of log messages
156+
> may change between releases.
157+
158+
#### Log levels
159+
160+
The log level can be configured in two ways:
161+
162+
1. Via the `LIGHTSWITCH_LOG` environment variable
163+
2. Using the `logLevel` client option (overrides the environment variable if set)
164+
165+
```ts
166+
import Lightswitch from 'lightswitch-api';
167+
168+
const client = new Lightswitch({
169+
logLevel: 'debug', // Show all log messages
170+
});
171+
```
172+
173+
Available log levels, from most to least verbose:
174+
175+
- `'debug'` - Show debug messages, info, warnings, and errors
176+
- `'info'` - Show info messages, warnings, and errors
177+
- `'warn'` - Show warnings and errors (default)
178+
- `'error'` - Show only errors
179+
- `'off'` - Disable all logging
180+
181+
At the `'debug'` level, all HTTP requests and responses are logged, including headers and bodies.
182+
Some authentication-related headers are redacted, but sensitive data in request and response bodies
183+
may still be visible.
184+
185+
#### Custom logger
186+
187+
By default, this library logs to `globalThis.console`. You can also provide a custom logger.
188+
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.
189+
190+
When providing a custom logger, the `logLevel` option still controls which messages are emitted, messages
191+
below the configured level will not be sent to your logger.
192+
193+
```ts
194+
import Lightswitch from 'lightswitch-api';
195+
import pino from 'pino';
196+
197+
const logger = pino();
198+
199+
const client = new Lightswitch({
200+
logger: logger.child({ name: 'Lightswitch' }),
201+
logLevel: 'debug', // Send all messages to pino, allowing it to filter
202+
});
203+
```
204+
152205
### Making custom/undocumented requests
153206

154207
This library is typed for convenient access to the documented API. If you need to access undocumented
@@ -208,33 +261,12 @@ globalThis.fetch = fetch;
208261
Or pass it to the client:
209262

210263
```ts
264+
import Lightswitch from 'lightswitch-api';
211265
import fetch from 'my-fetch';
212266

213267
const client = new Lightswitch({ fetch });
214268
```
215269

216-
### Logging and middleware
217-
218-
You may also provide a custom `fetch` function when instantiating the client,
219-
which can be used to inspect or alter the `Request` or `Response` before/after each request:
220-
221-
```ts
222-
import { fetch } from 'undici'; // as one example
223-
import Lightswitch from 'lightswitch-api';
224-
225-
const client = new Lightswitch({
226-
fetch: async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
227-
console.log('About to make a request', url, init);
228-
const response = await fetch(url, init);
229-
console.log('Got response', response);
230-
return response;
231-
},
232-
});
233-
```
234-
235-
Note that if given a `LIGHTSWITCH_LOG=debug` environment variable, this library will log all requests and responses automatically.
236-
This is intended for debugging purposes only and may change in the future without notice.
237-
238270
### Fetch options
239271

240272
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.)

Diff for: src/client.ts

+134-36
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';
@@ -22,7 +22,7 @@ import { Project, ProjectCreateParams, ProjectListResponse, Projects } from './r
2222
import { Task, TaskCreateParams, TaskListResponse, TaskUpdateParams, Tasks } from './resources/tasks';
2323
import { User, UserCreateParams, UserListResponse, Users } from './resources/users';
2424
import { readEnv } from './internal/utils/env';
25-
import { logger } from './internal/utils/log';
25+
import { formatRequestDetails, loggerFor } from './internal/utils/log';
2626
import { isEmptyObj } from './internal/utils/values';
2727

2828
const safeJSON = (text: string) => {
@@ -41,15 +41,30 @@ export type Logger = {
4141
debug: LogFn;
4242
};
4343
export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug';
44-
const isLogLevel = (key: string | undefined): key is LogLevel => {
44+
const parseLogLevel = (
45+
maybeLevel: string | undefined,
46+
sourceName: string,
47+
client: Lightswitch,
48+
): LogLevel | undefined => {
49+
if (!maybeLevel) {
50+
return undefined;
51+
}
4552
const levels: Record<LogLevel, true> = {
4653
off: true,
4754
error: true,
4855
warn: true,
4956
info: true,
5057
debug: true,
5158
};
52-
return key! in levels;
59+
if (hasOwn(levels, maybeLevel)) {
60+
return maybeLevel;
61+
}
62+
loggerFor(client).warn(
63+
`${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify(
64+
Object.keys(levels),
65+
)}`,
66+
);
67+
return undefined;
5368
};
5469

5570
export interface ClientOptions {
@@ -108,16 +123,16 @@ export interface ClientOptions {
108123
/**
109124
* Set the log level.
110125
*
111-
* Defaults to process.env['LIGHTSWITCH_LOG'].
126+
* Defaults to process.env['LIGHTSWITCH_LOG'] or 'warn' if it isn't set.
112127
*/
113-
logLevel?: LogLevel | undefined | null;
128+
logLevel?: LogLevel | undefined;
114129

115130
/**
116131
* Set the logger.
117132
*
118133
* Defaults to globalThis.console.
119134
*/
120-
logger?: Logger | undefined | null;
135+
logger?: Logger | undefined;
121136
}
122137

123138
type FinalizedRequestInit = RequestInit & { headers: Headers };
@@ -158,14 +173,13 @@ export class Lightswitch {
158173
this.baseURL = options.baseURL!;
159174
this.timeout = options.timeout ?? Lightswitch.DEFAULT_TIMEOUT /* 1 minute */;
160175
this.logger = options.logger ?? console;
161-
if (options.logLevel != null) {
162-
this.logLevel = options.logLevel;
163-
} else {
164-
const envLevel = readEnv('LIGHTSWITCH_LOG');
165-
if (isLogLevel(envLevel)) {
166-
this.logLevel = envLevel;
167-
}
168-
}
176+
const defaultLogLevel = 'warn';
177+
// Set default logLevel early so that we can log a warning in parseLogLevel.
178+
this.logLevel = defaultLogLevel;
179+
this.logLevel =
180+
parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ??
181+
parseLogLevel(readEnv('LIGHTSWITCH_LOG'), "process.env['LIGHTSWITCH_LOG']", this) ??
182+
defaultLogLevel;
169183
this.fetchOptions = options.fetchOptions;
170184
this.maxRetries = options.maxRetries ?? 2;
171185
this.fetch = options.fetch ?? Shims.getDefaultFetch();
@@ -289,12 +303,13 @@ export class Lightswitch {
289303
options: PromiseOrValue<FinalRequestOptions>,
290304
remainingRetries: number | null = null,
291305
): APIPromise<Rsp> {
292-
return new APIPromise(this, this.makeRequest(options, remainingRetries));
306+
return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined));
293307
}
294308

295309
private async makeRequest(
296310
optionsInput: PromiseOrValue<FinalRequestOptions>,
297311
retriesRemaining: number | null,
312+
retryOfRequestLogID: string | undefined,
298313
): Promise<APIResponseProps> {
299314
const options = await optionsInput;
300315
const maxRetries = options.maxRetries ?? this.maxRetries;
@@ -308,60 +323,142 @@ export class Lightswitch {
308323

309324
await this.prepareRequest(req, { url, options });
310325

311-
logger(this).debug('request', url, options, req.headers);
326+
/** Not an API request ID, just for correlating local log entries. */
327+
const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0');
328+
const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`;
329+
const startTime = Date.now();
330+
331+
loggerFor(this).debug(
332+
`[${requestLogID}] sending request`,
333+
formatRequestDetails({
334+
retryOfRequestLogID,
335+
method: options.method,
336+
url,
337+
options,
338+
headers: req.headers,
339+
}),
340+
);
312341

313342
if (options.signal?.aborted) {
314343
throw new Errors.APIUserAbortError();
315344
}
316345

317346
const controller = new AbortController();
318347
const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError);
348+
const headersTime = Date.now();
319349

320350
if (response instanceof Error) {
351+
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
321352
if (options.signal?.aborted) {
322353
throw new Errors.APIUserAbortError();
323354
}
324-
if (retriesRemaining) {
325-
return this.retryRequest(options, retriesRemaining);
326-
}
327-
if (isAbortError(response)) {
328-
throw new Errors.APIConnectionTimeoutError();
329-
}
330355
// detect native connection timeout errors
331356
// 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)"
332357
// undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)"
333358
// others do not provide enough information to distinguish timeouts from other connection errors
334-
if (/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''))) {
359+
const isTimeout =
360+
isAbortError(response) ||
361+
/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''));
362+
if (retriesRemaining) {
363+
loggerFor(this).info(
364+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`,
365+
);
366+
loggerFor(this).debug(
367+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`,
368+
formatRequestDetails({
369+
retryOfRequestLogID,
370+
url,
371+
durationMs: headersTime - startTime,
372+
message: response.message,
373+
}),
374+
);
375+
return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID);
376+
}
377+
loggerFor(this).info(
378+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`,
379+
);
380+
loggerFor(this).debug(
381+
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`,
382+
formatRequestDetails({
383+
retryOfRequestLogID,
384+
url,
385+
durationMs: headersTime - startTime,
386+
message: response.message,
387+
}),
388+
);
389+
if (isTimeout) {
335390
throw new Errors.APIConnectionTimeoutError();
336391
}
337392
throw new Errors.APIConnectionError({ cause: response });
338393
}
339394

395+
const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${
396+
response.ok ? 'succeeded' : 'failed'
397+
} with status ${response.status} in ${headersTime - startTime}ms`;
398+
340399
if (!response.ok) {
341-
if (retriesRemaining && this.shouldRetry(response)) {
400+
const shouldRetry = this.shouldRetry(response);
401+
if (retriesRemaining && shouldRetry) {
342402
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
343-
logger(this).debug(`response (error; ${retryMessage})`, response.status, url, response.headers);
344-
return this.retryRequest(options, retriesRemaining, response.headers);
403+
404+
// We don't need the body of this response.
405+
await Shims.CancelReadableStream(response.body);
406+
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
407+
loggerFor(this).debug(
408+
`[${requestLogID}] response error (${retryMessage})`,
409+
formatRequestDetails({
410+
retryOfRequestLogID,
411+
url: response.url,
412+
status: response.status,
413+
headers: response.headers,
414+
durationMs: headersTime - startTime,
415+
}),
416+
);
417+
return this.retryRequest(
418+
options,
419+
retriesRemaining,
420+
retryOfRequestLogID ?? requestLogID,
421+
response.headers,
422+
);
345423
}
346424

425+
const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`;
426+
427+
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
428+
347429
const errText = await response.text().catch((err: any) => castToError(err).message);
348430
const errJSON = safeJSON(errText);
349431
const errMessage = errJSON ? undefined : errText;
350-
const retryMessage = retriesRemaining ? `(error; no more retries left)` : `(error; not retryable)`;
351432

352-
logger(this).debug(
353-
`response (error; ${retryMessage})`,
354-
response.status,
355-
url,
356-
response.headers,
357-
errMessage,
433+
loggerFor(this).debug(
434+
`[${requestLogID}] response error (${retryMessage})`,
435+
formatRequestDetails({
436+
retryOfRequestLogID,
437+
url: response.url,
438+
status: response.status,
439+
headers: response.headers,
440+
message: errMessage,
441+
durationMs: Date.now() - startTime,
442+
}),
358443
);
359444

360445
const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers);
361446
throw err;
362447
}
363448

364-
return { response, options, controller };
449+
loggerFor(this).info(responseInfo);
450+
loggerFor(this).debug(
451+
`[${requestLogID}] response start`,
452+
formatRequestDetails({
453+
retryOfRequestLogID,
454+
url: response.url,
455+
status: response.status,
456+
headers: response.headers,
457+
durationMs: headersTime - startTime,
458+
}),
459+
);
460+
461+
return { response, options, controller, requestLogID, retryOfRequestLogID, startTime };
365462
}
366463

367464
async fetchWithTimeout(
@@ -423,6 +520,7 @@ export class Lightswitch {
423520
private async retryRequest(
424521
options: FinalRequestOptions,
425522
retriesRemaining: number,
523+
requestLogID: string,
426524
responseHeaders?: Headers | undefined,
427525
): Promise<APIResponseProps> {
428526
let timeoutMillis: number | undefined;
@@ -455,7 +553,7 @@ export class Lightswitch {
455553
}
456554
await sleep(timeoutMillis);
457555

458-
return this.makeRequest(options, retriesRemaining - 1);
556+
return this.makeRequest(options, retriesRemaining - 1, requestLogID);
459557
}
460558

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

Diff for: src/internal/errors.ts

+11
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ export function isAbortError(err: unknown) {
1414
export const castToError = (err: any): Error => {
1515
if (err instanceof Error) return err;
1616
if (typeof err === 'object' && err !== null) {
17+
try {
18+
if (Object.prototype.toString.call(err) === '[object Error]') {
19+
// @ts-ignore - not all envs have native support for cause yet
20+
const error = new Error(err.message, err.cause ? { cause: err.cause } : {});
21+
if (err.stack) error.stack = err.stack;
22+
// @ts-ignore - not all envs have native support for cause yet
23+
if (err.cause && !error.cause) error.cause = err.cause;
24+
if (err.name) error.name = err.name;
25+
throw error;
26+
}
27+
} catch {}
1728
try {
1829
return new Error(JSON.stringify(err));
1930
} catch {}

0 commit comments

Comments
 (0)