Skip to content

Commit

Permalink
feat(client): improve logging (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
stainless-app[bot] committed Feb 22, 2025
1 parent 7f7be64 commit 4818115
Showing 8 changed files with 377 additions and 112 deletions.
76 changes: 54 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -149,6 +149,59 @@ console.log(raw.headers.get('X-My-Header'));
console.log(projects);
```

### Logging

> [!IMPORTANT]
> All log messages are intended for debugging only. The format and content of log messages
> may change between releases.
#### Log levels

The log level can be configured in two ways:

1. Via the `LIGHTSWITCH_LOG` environment variable
2. Using the `logLevel` client option (overrides the environment variable if set)

```ts
import Lightswitch from 'lightswitch-api';

const client = new Lightswitch({
logLevel: 'debug', // Show all log messages
});
```

Available log levels, from most to least verbose:

- `'debug'` - Show debug messages, info, warnings, and errors
- `'info'` - Show info messages, warnings, and errors
- `'warn'` - Show warnings and errors (default)
- `'error'` - Show only errors
- `'off'` - Disable all logging

At the `'debug'` level, all HTTP requests and responses are logged, including headers and bodies.
Some authentication-related headers are redacted, but sensitive data in request and response bodies
may still be visible.

#### Custom logger

By default, this library logs to `globalThis.console`. You can also provide a custom logger.
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.

When providing a custom logger, the `logLevel` option still controls which messages are emitted, messages
below the configured level will not be sent to your logger.

```ts
import Lightswitch from 'lightswitch-api';
import pino from 'pino';

const logger = pino();

const client = new Lightswitch({
logger: logger.child({ name: 'Lightswitch' }),
logLevel: 'debug', // Send all messages to pino, allowing it to filter
});
```

### Making custom/undocumented requests

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

```ts
import Lightswitch from 'lightswitch-api';
import fetch from 'my-fetch';

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

### Logging and middleware

You may also provide a custom `fetch` function when instantiating the client,
which can be used to inspect or alter the `Request` or `Response` before/after each request:

```ts
import { fetch } from 'undici'; // as one example
import Lightswitch from 'lightswitch-api';

const client = new Lightswitch({
fetch: async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
console.log('About to make a request', url, init);
const response = await fetch(url, init);
console.log('Got response', response);
return response;
},
});
```

Note that if given a `LIGHTSWITCH_LOG=debug` environment variable, this library will log all requests and responses automatically.
This is intended for debugging purposes only and may change in the future without notice.

### Fetch options

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.)
170 changes: 134 additions & 36 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@
import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types';
import type { HTTPMethod, PromiseOrValue, MergedRequestInit } from './internal/types';
import { uuid4 } from './internal/utils/uuid';
import { validatePositiveInteger, isAbsoluteURL } from './internal/utils/values';
import { validatePositiveInteger, isAbsoluteURL, hasOwn } from './internal/utils/values';
import { sleep } from './internal/utils/sleep';
import { castToError, isAbortError } from './internal/errors';
import type { APIResponseProps } from './internal/parse';
@@ -22,7 +22,7 @@ import { Project, ProjectCreateParams, ProjectListResponse, Projects } from './r
import { Task, TaskCreateParams, TaskListResponse, TaskUpdateParams, Tasks } from './resources/tasks';
import { User, UserCreateParams, UserListResponse, Users } from './resources/users';
import { readEnv } from './internal/utils/env';
import { logger } from './internal/utils/log';
import { formatRequestDetails, loggerFor } from './internal/utils/log';
import { isEmptyObj } from './internal/utils/values';

const safeJSON = (text: string) => {
@@ -41,15 +41,30 @@ export type Logger = {
debug: LogFn;
};
export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug';
const isLogLevel = (key: string | undefined): key is LogLevel => {
const parseLogLevel = (
maybeLevel: string | undefined,
sourceName: string,
client: Lightswitch,
): LogLevel | undefined => {
if (!maybeLevel) {
return undefined;
}
const levels: Record<LogLevel, true> = {
off: true,
error: true,
warn: true,
info: true,
debug: true,
};
return key! in levels;
if (hasOwn(levels, maybeLevel)) {
return maybeLevel;
}
loggerFor(client).warn(
`${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify(
Object.keys(levels),
)}`,
);
return undefined;
};

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

/**
* Set the logger.
*
* Defaults to globalThis.console.
*/
logger?: Logger | undefined | null;
logger?: Logger | undefined;
}

type FinalizedRequestInit = RequestInit & { headers: Headers };
@@ -158,14 +173,13 @@ export class Lightswitch {
this.baseURL = options.baseURL!;
this.timeout = options.timeout ?? Lightswitch.DEFAULT_TIMEOUT /* 1 minute */;
this.logger = options.logger ?? console;
if (options.logLevel != null) {
this.logLevel = options.logLevel;
} else {
const envLevel = readEnv('LIGHTSWITCH_LOG');
if (isLogLevel(envLevel)) {
this.logLevel = envLevel;
}
}
const defaultLogLevel = 'warn';
// Set default logLevel early so that we can log a warning in parseLogLevel.
this.logLevel = defaultLogLevel;
this.logLevel =
parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ??
parseLogLevel(readEnv('LIGHTSWITCH_LOG'), "process.env['LIGHTSWITCH_LOG']", this) ??
defaultLogLevel;
this.fetchOptions = options.fetchOptions;
this.maxRetries = options.maxRetries ?? 2;
this.fetch = options.fetch ?? Shims.getDefaultFetch();
@@ -289,12 +303,13 @@ export class Lightswitch {
options: PromiseOrValue<FinalRequestOptions>,
remainingRetries: number | null = null,
): APIPromise<Rsp> {
return new APIPromise(this, this.makeRequest(options, remainingRetries));
return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined));
}

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

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

logger(this).debug('request', url, options, req.headers);
/** Not an API request ID, just for correlating local log entries. */
const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0');
const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`;
const startTime = Date.now();

loggerFor(this).debug(
`[${requestLogID}] sending request`,
formatRequestDetails({
retryOfRequestLogID,
method: options.method,
url,
options,
headers: req.headers,
}),
);

if (options.signal?.aborted) {
throw new Errors.APIUserAbortError();
}

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

if (response instanceof Error) {
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
if (options.signal?.aborted) {
throw new Errors.APIUserAbortError();
}
if (retriesRemaining) {
return this.retryRequest(options, retriesRemaining);
}
if (isAbortError(response)) {
throw new Errors.APIConnectionTimeoutError();
}
// detect native connection timeout errors
// 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)"
// undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)"
// others do not provide enough information to distinguish timeouts from other connection errors
if (/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''))) {
const isTimeout =
isAbortError(response) ||
/timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : ''));
if (retriesRemaining) {
loggerFor(this).info(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`,
);
loggerFor(this).debug(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`,
formatRequestDetails({
retryOfRequestLogID,
url,
durationMs: headersTime - startTime,
message: response.message,
}),
);
return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID);
}
loggerFor(this).info(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`,
);
loggerFor(this).debug(
`[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`,
formatRequestDetails({
retryOfRequestLogID,
url,
durationMs: headersTime - startTime,
message: response.message,
}),
);
if (isTimeout) {
throw new Errors.APIConnectionTimeoutError();
}
throw new Errors.APIConnectionError({ cause: response });
}

const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${
response.ok ? 'succeeded' : 'failed'
} with status ${response.status} in ${headersTime - startTime}ms`;

if (!response.ok) {
if (retriesRemaining && this.shouldRetry(response)) {
const shouldRetry = this.shouldRetry(response);
if (retriesRemaining && shouldRetry) {
const retryMessage = `retrying, ${retriesRemaining} attempts remaining`;
logger(this).debug(`response (error; ${retryMessage})`, response.status, url, response.headers);
return this.retryRequest(options, retriesRemaining, response.headers);

// We don't need the body of this response.
await Shims.CancelReadableStream(response.body);
loggerFor(this).info(`${responseInfo} - ${retryMessage}`);
loggerFor(this).debug(
`[${requestLogID}] response error (${retryMessage})`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
headers: response.headers,
durationMs: headersTime - startTime,
}),
);
return this.retryRequest(
options,
retriesRemaining,
retryOfRequestLogID ?? requestLogID,
response.headers,
);
}

const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`;

loggerFor(this).info(`${responseInfo} - ${retryMessage}`);

const errText = await response.text().catch((err: any) => castToError(err).message);
const errJSON = safeJSON(errText);
const errMessage = errJSON ? undefined : errText;
const retryMessage = retriesRemaining ? `(error; no more retries left)` : `(error; not retryable)`;

logger(this).debug(
`response (error; ${retryMessage})`,
response.status,
url,
response.headers,
errMessage,
loggerFor(this).debug(
`[${requestLogID}] response error (${retryMessage})`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
headers: response.headers,
message: errMessage,
durationMs: Date.now() - startTime,
}),
);

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

return { response, options, controller };
loggerFor(this).info(responseInfo);
loggerFor(this).debug(
`[${requestLogID}] response start`,
formatRequestDetails({
retryOfRequestLogID,
url: response.url,
status: response.status,
headers: response.headers,
durationMs: headersTime - startTime,
}),
);

return { response, options, controller, requestLogID, retryOfRequestLogID, startTime };
}

async fetchWithTimeout(
@@ -423,6 +520,7 @@ export class Lightswitch {
private async retryRequest(
options: FinalRequestOptions,
retriesRemaining: number,
requestLogID: string,
responseHeaders?: Headers | undefined,
): Promise<APIResponseProps> {
let timeoutMillis: number | undefined;
@@ -455,7 +553,7 @@ export class Lightswitch {
}
await sleep(timeoutMillis);

return this.makeRequest(options, retriesRemaining - 1);
return this.makeRequest(options, retriesRemaining - 1, requestLogID);
}

private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number {
11 changes: 11 additions & 0 deletions src/internal/errors.ts
Original file line number Diff line number Diff line change
@@ -14,6 +14,17 @@ export function isAbortError(err: unknown) {
export const castToError = (err: any): Error => {
if (err instanceof Error) return err;
if (typeof err === 'object' && err !== null) {
try {
if (Object.prototype.toString.call(err) === '[object Error]') {
// @ts-ignore - not all envs have native support for cause yet
const error = new Error(err.message, err.cause ? { cause: err.cause } : {});
if (err.stack) error.stack = err.stack;
// @ts-ignore - not all envs have native support for cause yet
if (err.cause && !error.cause) error.cause = err.cause;
if (err.name) error.name = err.name;
throw error;
}
} catch {}
try {
return new Error(JSON.stringify(err));
} catch {}
Loading
Oops, something went wrong.

0 comments on commit 4818115

Please sign in to comment.