Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

release: 0.1.0-alpha.3 #14

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
676a868
chore(internal): remove unused method (#13)
stainless-app[bot] Feb 13, 2025
a0b504c
chore(internal): codegen related update (#16)
stainless-app[bot] Feb 13, 2025
c41e942
chore(internal): update eslint config (#17)
stainless-app[bot] Feb 14, 2025
7d38d98
fix(client): fix export map for index exports, accept BunFile (#18)
stainless-app[bot] Feb 14, 2025
7f7be64
chore(internal): fix tests not always being type checked (#19)
stainless-app[bot] Feb 18, 2025
4818115
feat(client): improve logging (#20)
stainless-app[bot] Feb 22, 2025
bdccc24
chore(internal): fix devcontainers setup (#21)
stainless-app[bot] Feb 22, 2025
50b5b64
fix(internal): return in castToError instead of throwing (#22)
stainless-app[bot] Feb 22, 2025
43587fa
chore(internal): remove unnecessary todo (#23)
stainless-app[bot] Feb 22, 2025
8e0ab12
docs: update URLs from stainlessapi.com to stainless.com (#24)
stainless-app[bot] Feb 28, 2025
c5e8df8
chore(client): only accept standard types for file uploads (#25)
stainless-app[bot] Mar 4, 2025
ca13c87
chore(internal): fix tests failing on node v18 (#26)
stainless-app[bot] Mar 4, 2025
c5d034a
chore(internal): constrain synckit dev dependency (#27)
stainless-app[bot] Mar 4, 2025
2bc2cb6
fix(client): fix TypeError with undefined File (#28)
stainless-app[bot] Mar 4, 2025
313a6f7
fix(internal): clean up undefined File test (#29)
stainless-app[bot] Mar 4, 2025
8211340
fix(tests): manually reset node:buffer File (#30)
stainless-app[bot] Mar 4, 2025
4d59d92
chore(types): improved go to definition on fetchOptions (#31)
stainless-app[bot] Mar 5, 2025
71c7c33
chore(docs): improve docs for withResponse/asResponse (#32)
stainless-app[bot] Mar 5, 2025
a47189e
release: 0.1.0-alpha.3
stainless-app[bot] Mar 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(client): improve logging (#20)
  • Loading branch information
stainless-app[bot] committed Feb 22, 2025
commit 4818115c707adaa7ef4afab2913e4082a263b4c3
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.