From d36a4cc4cb0cdd128d0a493be4c9ee8bae17fdf7 Mon Sep 17 00:00:00 2001 From: hamdymohamedak Date: Sat, 18 Apr 2026 22:19:24 +0200 Subject: [PATCH] feat: structured client debug pipeline (debug, logger, stages) - Add OpenFetchConfig.debug and logger with OpenFetchDebugEvent - Emit lifecycle events from client, dispatch, and retry (basic vs verbose) - Redact URLs and mask headers in debug output; classify retry reasons - Export OpenFetchDebugEvent; add debug-pipeline tests Made-with: Cursor --- dist/domain/types.d.ts | 13 +++ dist/domain/types.d.ts.map | 2 +- dist/index.d.ts | 2 +- dist/index.d.ts.map | 2 +- dist/runtime/client.d.ts.map | 2 +- dist/runtime/client.js | 79 +++++++++++++---- dist/runtime/retry.d.ts.map | 2 +- dist/runtime/retry.js | 18 ++++ dist/shared/openFetchDebug.d.ts | 16 ++++ dist/shared/openFetchDebug.d.ts.map | 1 + dist/shared/openFetchDebug.js | 109 +++++++++++++++++++++++ dist/transport/dispatch.d.ts.map | 2 +- dist/transport/dispatch.js | 49 +++++++++- src/domain/types.ts | 14 +++ src/index.ts | 1 + src/runtime/client.ts | 101 ++++++++++++++++----- src/runtime/retry.ts | 22 +++++ src/shared/openFetchDebug.ts | 131 +++++++++++++++++++++++++++ src/transport/dispatch.ts | 60 ++++++++++++- test/debug-pipeline.test.mjs | 133 ++++++++++++++++++++++++++++ 20 files changed, 711 insertions(+), 48 deletions(-) create mode 100644 dist/shared/openFetchDebug.d.ts create mode 100644 dist/shared/openFetchDebug.d.ts.map create mode 100644 dist/shared/openFetchDebug.js create mode 100644 src/shared/openFetchDebug.ts create mode 100644 test/debug-pipeline.test.mjs diff --git a/dist/domain/types.d.ts b/dist/domain/types.d.ts index 55f17b6..de75bcb 100644 --- a/dist/domain/types.d.ts +++ b/dist/domain/types.d.ts @@ -4,6 +4,11 @@ import type { StandardSchemaV1 } from "./standardSchema.js"; export type NextFn = () => Promise; export type TransformRequest = (data: unknown, headers: Record) => unknown | Promise; export type TransformResponse = (data: unknown) => T | Promise; +/** Structured record emitted when {@link OpenFetchConfig.debug} is enabled. */ +export type OpenFetchDebugEvent = { + stage: string; + timestamp: number; +} & Record; /** Per-request overrides for the memory cache middleware. */ export type OpenFetchMemoryCacheRequestOptions = { ttlMs?: number; @@ -77,6 +82,14 @@ export type OpenFetchConfig = { * Default false — return full `OpenFetchResponse`. */ unwrapResponse?: boolean; + /** + * DevTools-style lifecycle logging. `true` / `"verbose"` emit structured events for the full + * pipeline (merge, fetch, retries, parse, schema). `"basic"` logs `request`, final `response`, + * and `error` only. Events go to {@link OpenFetchConfig.logger} or `console.debug`. + */ + debug?: boolean | "basic" | "verbose"; + /** Custom sink for structured {@link OpenFetchDebugEvent} records when `debug` is enabled. */ + logger?: (log: OpenFetchDebugEvent) => void; } & Partial>; export type OpenFetchResponse = { data: T; diff --git a/dist/domain/types.d.ts.map b/dist/domain/types.d.ts.map index cf0e01e..0010376 100644 --- a/dist/domain/types.d.ts.map +++ b/dist/domain/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/domain/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/domain/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 0e958ae..f88891a 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -19,5 +19,5 @@ export { cloneResponse } from "./shared/cloneResponse.js"; export { SchemaValidationError, isSchemaValidationError, } from "./domain/schemaValidationError.js"; export { OpenFetchForceRetry, isOpenFetchForceRetry, } from "./domain/forceRetry.js"; export type { StandardSchemaV1, StandardSchemaV1InferOutput, StandardSchemaV1Issue, StandardSchemaV1Options, StandardSchemaV1Result, StandardSchemaV1SuccessResult, StandardSchemaV1FailureResult, StandardSchemaV1Types, } from "./domain/standardSchema.js"; -export type { Middleware, NextFn, OpenFetchClient, OpenFetchConfig, OpenFetchContext, OpenFetchInterceptors, OpenFetchMemoryCacheRequestOptions, OpenFetchResponse, OpenFetchRetryOptions, RequestConfig, TransformRequest, TransformResponse, } from "./types/index.js"; +export type { Middleware, NextFn, OpenFetchClient, OpenFetchConfig, OpenFetchContext, OpenFetchDebugEvent, OpenFetchInterceptors, OpenFetchMemoryCacheRequestOptions, OpenFetchResponse, OpenFetchRetryOptions, RequestConfig, TransformRequest, TransformResponse, } from "./types/index.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 7299f70..d7e4e39 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAE3D,QAAA,MAAM,SAAS,sCAAiB,CAAC;AAEjC,eAAe,SAAS,CAAC;AAEzB,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC;AAEhC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EACL,KAAK,EACL,OAAO,EACP,KAAK,EACL,KAAK,EACL,WAAW,GACZ,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,EACf,UAAU,GACX,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,mBAAmB,EACnB,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EACL,qBAAqB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,yBAAyB,EACzB,qBAAqB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,GAC7B,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAClE,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,0BAA0B,GAC3B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,uBAAuB,EACvB,mCAAmC,EACnC,KAAK,qBAAqB,GAC3B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE1D,OAAO,EACL,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,2BAA2B,EAC3B,qBAAqB,EACrB,uBAAuB,EACvB,sBAAsB,EACtB,6BAA6B,EAC7B,6BAA6B,EAC7B,qBAAqB,GACtB,MAAM,4BAA4B,CAAC;AAEpC,YAAY,EACV,UAAU,EACV,MAAM,EACN,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,qBAAqB,EACrB,kCAAkC,EAClC,iBAAiB,EACjB,qBAAqB,EACrB,aAAa,EACb,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAE3D,QAAA,MAAM,SAAS,sCAAiB,CAAC;AAEjC,eAAe,SAAS,CAAC;AAEzB,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,CAAC;AAEhC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EACL,KAAK,EACL,OAAO,EACP,KAAK,EACL,KAAK,EACL,WAAW,GACZ,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,eAAe,EACf,UAAU,GACX,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,mBAAmB,EACnB,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EACL,qBAAqB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,yBAAyB,EACzB,qBAAqB,EACrB,KAAK,sBAAsB,EAC3B,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,GAC7B,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAClE,OAAO,EACL,sBAAsB,EACtB,uBAAuB,EACvB,0BAA0B,GAC3B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,GACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,uBAAuB,EACvB,mCAAmC,EACnC,KAAK,qBAAqB,GAC3B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE1D,OAAO,EACL,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,2BAA2B,EAC3B,qBAAqB,EACrB,uBAAuB,EACvB,sBAAsB,EACtB,6BAA6B,EAC7B,6BAA6B,EAC7B,qBAAqB,GACtB,MAAM,4BAA4B,CAAC;AAEpC,YAAY,EACV,UAAU,EACV,MAAM,EACN,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,mBAAmB,EACnB,qBAAqB,EACrB,kCAAkC,EAClC,iBAAiB,EACjB,qBAAqB,EACrB,aAAa,EACb,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC"} \ No newline at end of file diff --git a/dist/runtime/client.d.ts.map b/dist/runtime/client.d.ts.map index f68a3f9..8a4a613 100644 --- a/dist/runtime/client.d.ts.map +++ b/dist/runtime/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/runtime/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EAGhB,MAAM,oBAAoB,CAAC;AA6B5B,wBAAgB,YAAY,CAAC,eAAe,GAAE,eAAoB,GAAG,eAAe,CA+FnF;AAED,sCAAsC;AACtC,eAAO,MAAM,MAAM,qBAAe,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/runtime/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EAGhB,MAAM,oBAAoB,CAAC;AAsC5B,wBAAgB,YAAY,CAAC,eAAe,GAAE,eAAoB,GAAG,eAAe,CAgJnF;AAED,sCAAsC;AACtC,eAAO,MAAM,MAAM,qBAAe,CAAC"} \ No newline at end of file diff --git a/dist/runtime/client.js b/dist/runtime/client.js index 6a9f61a..5d0c0fc 100644 --- a/dist/runtime/client.js +++ b/dist/runtime/client.js @@ -1,5 +1,8 @@ +import { OpenFetchError } from "../domain/error.js"; import { InterceptorManager } from "../domain/interceptors.js"; +import { buildURL } from "../shared/buildURL.js"; import { mergeConfig } from "../shared/mergeConfig.js"; +import { emitOpenFetchDebug, headersForDebugLog, monotonicNowMs, redactUrlForDebug, safeMergedConfigMeta, simplifyStack, } from "../shared/openFetchDebug.js"; import { openFetchConfigFromRequest } from "../shared/requestFromNative.js"; import { dispatch } from "../transport/dispatch.js"; import { applyMiddlewares } from "./middleware.js"; @@ -34,32 +37,70 @@ export function createClient(initialDefaults = {}) { else { merged = mergeConfig(defaults, urlOrConfig); } + emitOpenFetchDebug(merged, "config", safeMergedConfigMeta(merged)); for (const fn of merged.init ?? []) { fn(merged); } + emitOpenFetchDebug(merged, "init", { + hooksRun: merged.init?.length ?? 0, + }); if (merged.url === undefined || merged.url === "") { throw new Error("openfetch: `url` is required"); } - const afterRequest = await requestInterceptors.runRequest(merged); - const ctx = { - url: afterRequest.url, - request: afterRequest, - response: null, - error: null, - }; - await applyMiddlewares(ctx, async () => { - const cfg = ctx.request; - ctx.response = await dispatch(cfg); - }); - // Stale ctx.error can remain from an earlier failed `next()` inside retry middleware; prefer a successful response. - if (ctx.error != null && ctx.response == null) - throw ctx.error; - let response = ctx.response; - response = (await responseInterceptors.runResponse(response)); - if (afterRequest.unwrapResponse) { - return response.data; + const t0 = monotonicNowMs(); + let cfgForLog = merged; + try { + const afterRequest = await requestInterceptors.runRequest(merged); + cfgForLog = afterRequest; + const resolvedUrl = buildURL(afterRequest.url, afterRequest); + const hdrs = afterRequest.headers + ? headersForDebugLog(Object.fromEntries(Object.entries(afterRequest.headers).map(([k, v]) => [ + k.toLowerCase(), + v, + ]))) + : undefined; + emitOpenFetchDebug(afterRequest, "request", { + method: (afterRequest.method ?? "GET").toUpperCase(), + url: redactUrlForDebug(resolvedUrl), + ...(hdrs ? { headers: hdrs } : {}), + }); + const ctx = { + url: afterRequest.url, + request: afterRequest, + response: null, + error: null, + }; + await applyMiddlewares(ctx, async () => { + const cfg = ctx.request; + ctx.response = await dispatch(cfg); + }); + // Stale ctx.error can remain from an earlier failed `next()` inside retry middleware; prefer a successful response. + if (ctx.error != null && ctx.response == null) + throw ctx.error; + let response = ctx.response; + response = (await responseInterceptors.runResponse(response)); + const durationMs = Math.round(monotonicNowMs() - t0); + emitOpenFetchDebug(cfgForLog, "response", { + status: response.status, + statusText: response.statusText, + durationMs, + }); + if (afterRequest.unwrapResponse) { + return response.data; + } + return response; + } + catch (e) { + const durationMs = Math.round(monotonicNowMs() - t0); + emitOpenFetchDebug(cfgForLog, "error", { + name: e instanceof Error ? e.name : "Error", + message: e instanceof Error ? e.message : String(e), + code: e instanceof OpenFetchError ? e.code : undefined, + stack: e instanceof Error ? simplifyStack(e.stack) : undefined, + durationMs, + }); + throw e; } - return response; } const client = { defaults, diff --git a/dist/runtime/retry.d.ts.map b/dist/runtime/retry.d.ts.map index 4e35476..39d3c28 100644 --- a/dist/runtime/retry.d.ts.map +++ b/dist/runtime/retry.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/runtime/retry.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EAGV,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AAyM5B;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,eAAe,CAAC,EAAE,qBAAqB,GACtC,UAAU,CA4FZ"} \ No newline at end of file +{"version":3,"file":"retry.d.ts","sourceRoot":"","sources":["../../src/runtime/retry.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,UAAU,EAGV,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AA8M5B;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,eAAe,CAAC,EAAE,qBAAqB,GACtC,UAAU,CA6GZ"} \ No newline at end of file diff --git a/dist/runtime/retry.js b/dist/runtime/retry.js index 0e43d40..ae33165 100644 --- a/dist/runtime/retry.js +++ b/dist/runtime/retry.js @@ -1,6 +1,7 @@ import { OpenFetchError } from "../domain/error.js"; import { OpenFetchForceRetry } from "../domain/forceRetry.js"; import { buildURL } from "../shared/buildURL.js"; +import { classifyRetryReason, emitOpenFetchDebug, setOpenFetchDebugAttempt, } from "../shared/openFetchDebug.js"; import { ensureIdempotencyKeyHeader, generateIdempotencyKey, hasIdempotencyKeyHeader, } from "../shared/idempotencyKey.js"; import { mergeAbortSignals } from "../shared/mergeAbortSignals.js"; const DEFAULT_RETRY_ON_STATUS = [408, 429, 500, 502, 503, 504]; @@ -193,6 +194,11 @@ export function createRetryMiddleware(factoryDefaults) { attempt += 1; throwIfExternalAborted(ctx); assertWithinRetryDeadline(ctx, deadlineMono); + emitOpenFetchDebug(ctx.request, "attempt_start", { + attempt, + maxAttempts: ro.maxAttempts, + }); + setOpenFetchDebugAttempt(ctx.request, attempt); try { if (ro.timeoutPerAttemptMs != null && ro.timeoutPerAttemptMs > 0) { ctx.request.timeout = ro.timeoutPerAttemptMs; @@ -259,6 +265,18 @@ export function createRetryMiddleware(factoryDefaults) { const sleepMs = deadlineMono == null ? delay : Math.min(delay, Math.max(0, deadlineMono - monotonicNowMs())); + if (err instanceof OpenFetchForceRetry) { + emitOpenFetchDebug(ctx.request, "hook_after_response", { + hook: "onAfterResponse", + action: "force_retry", + }); + } + emitOpenFetchDebug(ctx.request, "retry", { + failedAttempt: attempt, + nextAttempt: attempt + 1, + reason: classifyRetryReason(err), + delayMs: sleepMs, + }); await sleepBackoff(sleepMs, ctx); } } diff --git a/dist/shared/openFetchDebug.d.ts b/dist/shared/openFetchDebug.d.ts new file mode 100644 index 0000000..8eee1bf --- /dev/null +++ b/dist/shared/openFetchDebug.d.ts @@ -0,0 +1,16 @@ +import type { OpenFetchConfig } from "../domain/types.js"; +/** @internal Retry layer tags the active attempt for dispatch-level verbose logs. */ +export declare const kOpenFetchDebugAttempt: unique symbol; +export type OpenFetchDebugRunLevel = "basic" | "verbose"; +export declare function resolveOpenFetchDebugLevel(debug: OpenFetchConfig["debug"]): false | OpenFetchDebugRunLevel; +export declare function getOpenFetchDebugAttempt(cfg: OpenFetchConfig): number; +export declare function setOpenFetchDebugAttempt(cfg: OpenFetchConfig, attempt: number): void; +export declare function clearOpenFetchDebugAttempt(cfg: OpenFetchConfig): void; +export declare function redactUrlForDebug(url: string): string; +export declare function headersForDebugLog(headers: Record): Record | undefined; +export declare function simplifyStack(stack: string | undefined): string | undefined; +export declare function monotonicNowMs(): number; +export declare function classifyRetryReason(err: unknown): string; +export declare function emitOpenFetchDebug(config: OpenFetchConfig, stage: string, meta?: Record): void; +export declare function safeMergedConfigMeta(cfg: OpenFetchConfig): Record; +//# sourceMappingURL=openFetchDebug.d.ts.map \ No newline at end of file diff --git a/dist/shared/openFetchDebug.d.ts.map b/dist/shared/openFetchDebug.d.ts.map new file mode 100644 index 0000000..9e423ff --- /dev/null +++ b/dist/shared/openFetchDebug.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"openFetchDebug.d.ts","sourceRoot":"","sources":["../../src/shared/openFetchDebug.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAuB,MAAM,oBAAoB,CAAC;AAI/E,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,eAAgD,CAAC;AAEpF,MAAM,MAAM,sBAAsB,GAAG,OAAO,GAAG,SAAS,CAAC;AAIzD,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,GAC9B,KAAK,GAAG,sBAAsB,CAIhC;AAED,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAGrE;AAED,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,GACd,IAAI,CAEN;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAErE;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAErD;AAED,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAGpC;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAI3E;AAED,wBAAgB,cAAc,IAAI,MAAM,CAMvC;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAaxD;AAeD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,eAAe,EACvB,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAiBN;AAED,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,eAAe,GACnB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAezB"} \ No newline at end of file diff --git a/dist/shared/openFetchDebug.js b/dist/shared/openFetchDebug.js new file mode 100644 index 0000000..b39ce48 --- /dev/null +++ b/dist/shared/openFetchDebug.js @@ -0,0 +1,109 @@ +import { OpenFetchError } from "../domain/error.js"; +import { OpenFetchForceRetry } from "../domain/forceRetry.js"; +import { maskHeaderValues } from "./maskHeaders.js"; +import { redactSensitiveUrlQuery } from "./redactUrlQuery.js"; +/** @internal Retry layer tags the active attempt for dispatch-level verbose logs. */ +export const kOpenFetchDebugAttempt = Symbol.for("openfetch.internal.debugAttempt"); +const BASIC_STAGES = new Set(["request", "response", "error"]); +export function resolveOpenFetchDebugLevel(debug) { + if (debug === true || debug === "verbose") + return "verbose"; + if (debug === "basic") + return "basic"; + return false; +} +export function getOpenFetchDebugAttempt(cfg) { + const v = cfg[kOpenFetchDebugAttempt]; + return typeof v === "number" && v > 0 ? v : 1; +} +export function setOpenFetchDebugAttempt(cfg, attempt) { + cfg[kOpenFetchDebugAttempt] = attempt; +} +export function clearOpenFetchDebugAttempt(cfg) { + Reflect.deleteProperty(cfg, kOpenFetchDebugAttempt); +} +export function redactUrlForDebug(url) { + return redactSensitiveUrlQuery(url, { enabled: true }); +} +export function headersForDebugLog(headers) { + const masked = maskHeaderValues(headers, undefined); + return masked ?? undefined; +} +export function simplifyStack(stack) { + if (stack == null || stack === "") + return undefined; + const lines = stack.split("\n").slice(0, 4); + return lines.join("\n"); +} +export function monotonicNowMs() { + const perf = globalThis.performance; + if (perf != null && typeof perf.now === "function") { + return perf.now(); + } + return Date.now(); +} +export function classifyRetryReason(err) { + if (err instanceof OpenFetchForceRetry) + return "forceRetry"; + if (err instanceof OpenFetchError) { + if (err.code === "ERR_TIMEOUT") + return "timeout"; + if (err.code === "ERR_BAD_RESPONSE" && err.response != null) { + return `http_${err.response.status}`; + } + if (err.code === "ERR_NETWORK") + return "network"; + if (err.code === "ERR_PARSE") + return "parse"; + if (err.code === "ERR_RETRY_TIMEOUT") + return "retryBudget"; + if (err.code === "ERR_CANCELED") + return "canceled"; + } + return "unknown"; +} +function defaultDebugLogger(log) { + if (typeof console === "undefined" || typeof console.debug !== "function") { + return; + } + const { stage, timestamp: _ts, ...rest } = log; + const keys = Object.keys(rest); + if (keys.length === 0) { + console.debug(`[OpenFetch] ${stage}`); + } + else { + console.debug(`[OpenFetch] ${stage}`, rest); + } +} +export function emitOpenFetchDebug(config, stage, meta) { + const level = resolveOpenFetchDebugLevel(config.debug); + if (!level) + return; + if (level === "basic" && !BASIC_STAGES.has(stage)) + return; + const event = { + stage, + timestamp: Date.now(), + ...(meta ?? {}), + }; + const sink = config.logger ?? defaultDebugLogger; + try { + sink(event); + } + catch { + // Never let diagnostics break requests. + } +} +export function safeMergedConfigMeta(cfg) { + const url = cfg.url === undefined + ? undefined + : redactUrlForDebug(typeof cfg.url === "string" ? cfg.url : cfg.url instanceof URL ? cfg.url.href : String(cfg.url)); + return { + method: (cfg.method ?? "GET").toUpperCase(), + url, + baseURL: cfg.baseURL, + responseType: cfg.responseType, + hasJsonSchema: cfg.jsonSchema != null, + retryMaxAttempts: cfg.retry?.maxAttempts, + }; +} diff --git a/dist/transport/dispatch.d.ts.map b/dist/transport/dispatch.d.ts.map index 2f60450..554e412 100644 --- a/dist/transport/dispatch.d.ts.map +++ b/dist/transport/dispatch.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../src/transport/dispatch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAwF7E,MAAM,MAAM,cAAc,GAAG,eAAe,GAAG;IAAE,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,CAAC;AAErE,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,iBAAiB,CAAC,CAmL5B"} \ No newline at end of file +{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../src/transport/dispatch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAgG7E,MAAM,MAAM,cAAc,GAAG,eAAe,GAAG;IAAE,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,CAAC;AAErE,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,iBAAiB,CAAC,CAqO5B"} \ No newline at end of file diff --git a/dist/transport/dispatch.js b/dist/transport/dispatch.js index e47a118..d21e7f4 100644 --- a/dist/transport/dispatch.js +++ b/dist/transport/dispatch.js @@ -4,6 +4,7 @@ import { validateJsonWithStandardSchema } from "../domain/validateJsonSchema.js" import { assertSafeHttpUrl } from "../shared/assertSafeHttpUrl.js"; import { buildURL } from "../shared/buildURL.js"; import { encodeBasicAuth } from "../shared/basicAuth.js"; +import { clearOpenFetchDebugAttempt, emitOpenFetchDebug, getOpenFetchDebugAttempt, headersForDebugLog, monotonicNowMs, redactUrlForDebug, } from "../shared/openFetchDebug.js"; import { mergeAbortSignals } from "../shared/mergeAbortSignals.js"; import { headersToRecord } from "../shared/responseHeaders.js"; const defaultValidateStatus = (status) => status >= 200 && status < 300; @@ -123,7 +124,15 @@ export async function dispatch(config) { }, config.timeout); } const signal = mergeAbortSignals(config.signal ?? undefined, controller); + const attempt = getOpenFetchDebugAttempt(config); + emitOpenFetchDebug(config, "fetch", { + attempt, + method: (config.method ?? "GET").toUpperCase(), + url: redactUrlForDebug(urlString), + headers: headersForDebugLog(headers), + }); try { + const tFetch = monotonicNowMs(); const res = await fetch(urlString, { method: (config.method ?? "GET").toUpperCase(), headers, @@ -138,6 +147,15 @@ export async function dispatch(config) { referrer: config.referrer, referrerPolicy: config.referrerPolicy, }); + const fetchDurationMs = Math.round(monotonicNowMs() - tFetch); + const contentLength = res.headers.get("content-length"); + emitOpenFetchDebug(config, "fetch_complete", { + attempt, + status: res.status, + statusText: res.statusText, + durationMs: fetchDurationMs, + contentLength: contentLength ?? undefined, + }); const headerRecord = headersToRecord(res.headers); if (config.rawResponse === true) { const openResponse = { @@ -155,13 +173,28 @@ export async function dispatch(config) { request: { url: urlString }, }); } + emitOpenFetchDebug(config, "parse", { + attempt, + skipped: true, + reason: "rawResponse", + }); return openResponse; } let parsed; try { parsed = await parseBody(res, config.responseType); + emitOpenFetchDebug(config, "parse", { + attempt, + ok: true, + responseType: config.responseType ?? "auto", + }); } catch { + emitOpenFetchDebug(config, "parse", { + attempt, + ok: false, + responseType: config.responseType ?? "auto", + }); throw new OpenFetchError("Response could not be parsed", { config, code: "ERR_PARSE", @@ -185,7 +218,20 @@ export async function dispatch(config) { } let outData = openResponse.data; if (config.jsonSchema != null) { - outData = await validateJsonWithStandardSchema(outData, config.jsonSchema); + try { + outData = await validateJsonWithStandardSchema(outData, config.jsonSchema); + emitOpenFetchDebug(config, "schema", { attempt, ok: true }); + } + catch (e) { + if (e instanceof SchemaValidationError) { + emitOpenFetchDebug(config, "schema", { + attempt, + ok: false, + issueCount: e.issues.length, + }); + } + throw e; + } } for (const tr of config.transformResponse ?? []) { outData = await tr(outData); @@ -234,6 +280,7 @@ export async function dispatch(config) { }); } finally { + clearOpenFetchDebugAttempt(config); // Clear per-attempt timer so it cannot fire after completion (avoids dangling timers / leaks). if (timeoutId !== undefined) { clearTimeout(timeoutId); diff --git a/src/domain/types.ts b/src/domain/types.ts index 15025ce..5bc558c 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -11,6 +11,12 @@ export type TransformRequest = ( export type TransformResponse = (data: unknown) => T | Promise; +/** Structured record emitted when {@link OpenFetchConfig.debug} is enabled. */ +export type OpenFetchDebugEvent = { + stage: string; + timestamp: number; +} & Record; + /** Per-request overrides for the memory cache middleware. */ export type OpenFetchMemoryCacheRequestOptions = { ttlMs?: number; @@ -82,6 +88,14 @@ export type OpenFetchConfig = { * Default false — return full `OpenFetchResponse`. */ unwrapResponse?: boolean; + /** + * DevTools-style lifecycle logging. `true` / `"verbose"` emit structured events for the full + * pipeline (merge, fetch, retries, parse, schema). `"basic"` logs `request`, final `response`, + * and `error` only. Events go to {@link OpenFetchConfig.logger} or `console.debug`. + */ + debug?: boolean | "basic" | "verbose"; + /** Custom sink for structured {@link OpenFetchDebugEvent} records when `debug` is enabled. */ + logger?: (log: OpenFetchDebugEvent) => void; } & Partial< Pick< RequestInit, diff --git a/src/index.ts b/src/index.ts index 6fa635c..4c4c3d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,6 +90,7 @@ export type { OpenFetchClient, OpenFetchConfig, OpenFetchContext, + OpenFetchDebugEvent, OpenFetchInterceptors, OpenFetchMemoryCacheRequestOptions, OpenFetchResponse, diff --git a/src/runtime/client.ts b/src/runtime/client.ts index f8197d0..efcdbb9 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,3 +1,4 @@ +import { OpenFetchError } from "../domain/error.js"; import { InterceptorManager } from "../domain/interceptors.js"; import type { OpenFetchClient, @@ -5,7 +6,16 @@ import type { OpenFetchResponse, RequestConfig, } from "../domain/types.js"; +import { buildURL } from "../shared/buildURL.js"; import { mergeConfig } from "../shared/mergeConfig.js"; +import { + emitOpenFetchDebug, + headersForDebugLog, + monotonicNowMs, + redactUrlForDebug, + safeMergedConfigMeta, + simplifyStack, +} from "../shared/openFetchDebug.js"; import { openFetchConfigFromRequest } from "../shared/requestFromNative.js"; import { dispatch } from "../transport/dispatch.js"; import { applyMiddlewares } from "./middleware.js"; @@ -54,42 +64,91 @@ export function createClient(initialDefaults: OpenFetchConfig = {}): OpenFetchCl merged = mergeConfig(defaults, urlOrConfig); } + emitOpenFetchDebug(merged, "config", safeMergedConfigMeta(merged)); + for (const fn of merged.init ?? []) { fn(merged); } + emitOpenFetchDebug(merged, "init", { + hooksRun: merged.init?.length ?? 0, + }); + if (merged.url === undefined || merged.url === "") { throw new Error("openfetch: `url` is required"); } - const afterRequest = await requestInterceptors.runRequest(merged); + const t0 = monotonicNowMs(); + let cfgForLog: OpenFetchConfig = merged; + + try { + const afterRequest = await requestInterceptors.runRequest(merged); + cfgForLog = afterRequest; - const ctx = { - url: afterRequest.url as string | URL, - request: afterRequest, - response: null as OpenFetchResponse | null, - error: null as unknown, - }; + const resolvedUrl = buildURL( + afterRequest.url as string | URL, + afterRequest + ); + const hdrs = afterRequest.headers + ? headersForDebugLog( + Object.fromEntries( + Object.entries(afterRequest.headers).map(([k, v]) => [ + k.toLowerCase(), + v, + ]) + ) + ) + : undefined; + emitOpenFetchDebug(afterRequest, "request", { + method: (afterRequest.method ?? "GET").toUpperCase(), + url: redactUrlForDebug(resolvedUrl), + ...(hdrs ? { headers: hdrs } : {}), + }); - await applyMiddlewares(ctx, async () => { - const cfg = ctx.request as OpenFetchConfig & { - url: string | URL; + const ctx = { + url: afterRequest.url as string | URL, + request: afterRequest, + response: null as OpenFetchResponse | null, + error: null as unknown, }; - ctx.response = await dispatch(cfg); - }); - // Stale ctx.error can remain from an earlier failed `next()` inside retry middleware; prefer a successful response. - if (ctx.error != null && ctx.response == null) throw ctx.error; + await applyMiddlewares(ctx, async () => { + const cfg = ctx.request as OpenFetchConfig & { + url: string | URL; + }; + ctx.response = await dispatch(cfg); + }); + + // Stale ctx.error can remain from an earlier failed `next()` inside retry middleware; prefer a successful response. + if (ctx.error != null && ctx.response == null) throw ctx.error; + + let response = ctx.response as OpenFetchResponse; + response = (await responseInterceptors.runResponse( + response + )) as OpenFetchResponse; - let response = ctx.response as OpenFetchResponse; - response = (await responseInterceptors.runResponse( - response - )) as OpenFetchResponse; + const durationMs = Math.round(monotonicNowMs() - t0); + emitOpenFetchDebug(cfgForLog, "response", { + status: response.status, + statusText: response.statusText, + durationMs, + }); - if (afterRequest.unwrapResponse) { - return response.data; + if (afterRequest.unwrapResponse) { + return response.data; + } + return response; + } catch (e) { + const durationMs = Math.round(monotonicNowMs() - t0); + emitOpenFetchDebug(cfgForLog, "error", { + name: e instanceof Error ? e.name : "Error", + message: e instanceof Error ? e.message : String(e), + code: e instanceof OpenFetchError ? e.code : undefined, + stack: e instanceof Error ? simplifyStack(e.stack) : undefined, + durationMs, + }); + throw e; } - return response; } const client: OpenFetchClient = { diff --git a/src/runtime/retry.ts b/src/runtime/retry.ts index 7805d19..193ee59 100644 --- a/src/runtime/retry.ts +++ b/src/runtime/retry.ts @@ -7,6 +7,11 @@ import type { OpenFetchRetryOptions, } from "../domain/types.js"; import { buildURL } from "../shared/buildURL.js"; +import { + classifyRetryReason, + emitOpenFetchDebug, + setOpenFetchDebugAttempt, +} from "../shared/openFetchDebug.js"; import { ensureIdempotencyKeyHeader, generateIdempotencyKey, @@ -240,6 +245,11 @@ export function createRetryMiddleware( attempt += 1; throwIfExternalAborted(ctx); assertWithinRetryDeadline(ctx, deadlineMono); + emitOpenFetchDebug(ctx.request, "attempt_start", { + attempt, + maxAttempts: ro.maxAttempts, + }); + setOpenFetchDebugAttempt(ctx.request, attempt); try { if (ro.timeoutPerAttemptMs != null && ro.timeoutPerAttemptMs > 0) { ctx.request.timeout = ro.timeoutPerAttemptMs; @@ -313,6 +323,18 @@ export function createRetryMiddleware( delay, Math.max(0, deadlineMono - monotonicNowMs()) ); + if (err instanceof OpenFetchForceRetry) { + emitOpenFetchDebug(ctx.request, "hook_after_response", { + hook: "onAfterResponse", + action: "force_retry", + }); + } + emitOpenFetchDebug(ctx.request, "retry", { + failedAttempt: attempt, + nextAttempt: attempt + 1, + reason: classifyRetryReason(err), + delayMs: sleepMs, + }); await sleepBackoff(sleepMs, ctx); } } diff --git a/src/shared/openFetchDebug.ts b/src/shared/openFetchDebug.ts new file mode 100644 index 0000000..d52cca7 --- /dev/null +++ b/src/shared/openFetchDebug.ts @@ -0,0 +1,131 @@ +import { OpenFetchError } from "../domain/error.js"; +import { OpenFetchForceRetry } from "../domain/forceRetry.js"; +import type { OpenFetchConfig, OpenFetchDebugEvent } from "../domain/types.js"; +import { maskHeaderValues } from "./maskHeaders.js"; +import { redactSensitiveUrlQuery } from "./redactUrlQuery.js"; + +/** @internal Retry layer tags the active attempt for dispatch-level verbose logs. */ +export const kOpenFetchDebugAttempt = Symbol.for("openfetch.internal.debugAttempt"); + +export type OpenFetchDebugRunLevel = "basic" | "verbose"; + +const BASIC_STAGES = new Set(["request", "response", "error"]); + +export function resolveOpenFetchDebugLevel( + debug: OpenFetchConfig["debug"] +): false | OpenFetchDebugRunLevel { + if (debug === true || debug === "verbose") return "verbose"; + if (debug === "basic") return "basic"; + return false; +} + +export function getOpenFetchDebugAttempt(cfg: OpenFetchConfig): number { + const v = (cfg as unknown as { [k: symbol]: unknown })[kOpenFetchDebugAttempt]; + return typeof v === "number" && v > 0 ? v : 1; +} + +export function setOpenFetchDebugAttempt( + cfg: OpenFetchConfig, + attempt: number +): void { + (cfg as unknown as { [k: symbol]: number })[kOpenFetchDebugAttempt] = attempt; +} + +export function clearOpenFetchDebugAttempt(cfg: OpenFetchConfig): void { + Reflect.deleteProperty(cfg as object, kOpenFetchDebugAttempt); +} + +export function redactUrlForDebug(url: string): string { + return redactSensitiveUrlQuery(url, { enabled: true }); +} + +export function headersForDebugLog( + headers: Record +): Record | undefined { + const masked = maskHeaderValues(headers, undefined); + return masked ?? undefined; +} + +export function simplifyStack(stack: string | undefined): string | undefined { + if (stack == null || stack === "") return undefined; + const lines = stack.split("\n").slice(0, 4); + return lines.join("\n"); +} + +export function monotonicNowMs(): number { + const perf = globalThis.performance; + if (perf != null && typeof perf.now === "function") { + return perf.now(); + } + return Date.now(); +} + +export function classifyRetryReason(err: unknown): string { + if (err instanceof OpenFetchForceRetry) return "forceRetry"; + if (err instanceof OpenFetchError) { + if (err.code === "ERR_TIMEOUT") return "timeout"; + if (err.code === "ERR_BAD_RESPONSE" && err.response != null) { + return `http_${err.response.status}`; + } + if (err.code === "ERR_NETWORK") return "network"; + if (err.code === "ERR_PARSE") return "parse"; + if (err.code === "ERR_RETRY_TIMEOUT") return "retryBudget"; + if (err.code === "ERR_CANCELED") return "canceled"; + } + return "unknown"; +} + +function defaultDebugLogger(log: OpenFetchDebugEvent): void { + if (typeof console === "undefined" || typeof console.debug !== "function") { + return; + } + const { stage, timestamp: _ts, ...rest } = log; + const keys = Object.keys(rest); + if (keys.length === 0) { + console.debug(`[OpenFetch] ${stage}`); + } else { + console.debug(`[OpenFetch] ${stage}`, rest); + } +} + +export function emitOpenFetchDebug( + config: OpenFetchConfig, + stage: string, + meta?: Record +): void { + const level = resolveOpenFetchDebugLevel(config.debug); + if (!level) return; + if (level === "basic" && !BASIC_STAGES.has(stage)) return; + + const event: OpenFetchDebugEvent = { + stage, + timestamp: Date.now(), + ...(meta ?? {}), + }; + + const sink = config.logger ?? defaultDebugLogger; + try { + sink(event); + } catch { + // Never let diagnostics break requests. + } +} + +export function safeMergedConfigMeta( + cfg: OpenFetchConfig +): Record { + const url = + cfg.url === undefined + ? undefined + : redactUrlForDebug( + typeof cfg.url === "string" ? cfg.url : cfg.url instanceof URL ? cfg.url.href : String(cfg.url) + ); + return { + method: (cfg.method ?? "GET").toUpperCase(), + url, + baseURL: cfg.baseURL, + responseType: cfg.responseType, + hasJsonSchema: cfg.jsonSchema != null, + retryMaxAttempts: cfg.retry?.maxAttempts, + }; +} diff --git a/src/transport/dispatch.ts b/src/transport/dispatch.ts index 245ab13..5ab140b 100644 --- a/src/transport/dispatch.ts +++ b/src/transport/dispatch.ts @@ -5,6 +5,14 @@ import { validateJsonWithStandardSchema } from "../domain/validateJsonSchema.js" import { assertSafeHttpUrl } from "../shared/assertSafeHttpUrl.js"; import { buildURL } from "../shared/buildURL.js"; import { encodeBasicAuth } from "../shared/basicAuth.js"; +import { + clearOpenFetchDebugAttempt, + emitOpenFetchDebug, + getOpenFetchDebugAttempt, + headersForDebugLog, + monotonicNowMs, + redactUrlForDebug, +} from "../shared/openFetchDebug.js"; import { mergeAbortSignals } from "../shared/mergeAbortSignals.js"; import { headersToRecord } from "../shared/responseHeaders.js"; @@ -151,7 +159,16 @@ export async function dispatch( const signal = mergeAbortSignals(config.signal ?? undefined, controller); + const attempt = getOpenFetchDebugAttempt(config); + emitOpenFetchDebug(config, "fetch", { + attempt, + method: (config.method ?? "GET").toUpperCase(), + url: redactUrlForDebug(urlString), + headers: headersForDebugLog(headers), + }); + try { + const tFetch = monotonicNowMs(); const res = await fetch(urlString, { method: (config.method ?? "GET").toUpperCase(), headers, @@ -167,6 +184,16 @@ export async function dispatch( referrerPolicy: config.referrerPolicy, }); + const fetchDurationMs = Math.round(monotonicNowMs() - tFetch); + const contentLength = res.headers.get("content-length"); + emitOpenFetchDebug(config, "fetch_complete", { + attempt, + status: res.status, + statusText: res.statusText, + durationMs: fetchDurationMs, + contentLength: contentLength ?? undefined, + }); + const headerRecord = headersToRecord(res.headers); if (config.rawResponse === true) { @@ -185,13 +212,28 @@ export async function dispatch( request: { url: urlString }, }); } + emitOpenFetchDebug(config, "parse", { + attempt, + skipped: true, + reason: "rawResponse", + }); return openResponse; } let parsed: unknown; try { parsed = await parseBody(res, config.responseType); + emitOpenFetchDebug(config, "parse", { + attempt, + ok: true, + responseType: config.responseType ?? "auto", + }); } catch { + emitOpenFetchDebug(config, "parse", { + attempt, + ok: false, + responseType: config.responseType ?? "auto", + }); throw new OpenFetchError("Response could not be parsed", { config, code: "ERR_PARSE", @@ -218,7 +260,22 @@ export async function dispatch( let outData: unknown = openResponse.data; if (config.jsonSchema != null) { - outData = await validateJsonWithStandardSchema(outData, config.jsonSchema); + try { + outData = await validateJsonWithStandardSchema( + outData, + config.jsonSchema + ); + emitOpenFetchDebug(config, "schema", { attempt, ok: true }); + } catch (e) { + if (e instanceof SchemaValidationError) { + emitOpenFetchDebug(config, "schema", { + attempt, + ok: false, + issueCount: e.issues.length, + }); + } + throw e; + } } for (const tr of config.transformResponse ?? []) { @@ -266,6 +323,7 @@ export async function dispatch( request: { url: urlString }, }); } finally { + clearOpenFetchDebugAttempt(config); // Clear per-attempt timer so it cannot fire after completion (avoids dangling timers / leaks). if (timeoutId !== undefined) { clearTimeout(timeoutId); diff --git a/test/debug-pipeline.test.mjs b/test/debug-pipeline.test.mjs new file mode 100644 index 0000000..7cf59af --- /dev/null +++ b/test/debug-pipeline.test.mjs @@ -0,0 +1,133 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createClient, + createRetryMiddleware, + OpenFetchForceRetry, +} from "../dist/index.js"; + +test("debug basic: only request, response, error stages", async () => { + const originalFetch = globalThis.fetch; + const stages = []; + globalThis.fetch = async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + const client = createClient({ + debug: "basic", + logger: (e) => stages.push(e.stage), + }); + await client.request("http://example.test/x", { unwrapResponse: true }); + assert.ok(stages.includes("request")); + assert.ok(stages.includes("response")); + assert.ok(!stages.includes("config")); + assert.ok(!stages.includes("fetch")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("debug verbose: lifecycle includes config, fetch, parse, schema", async () => { + const originalFetch = globalThis.fetch; + const stages = []; + globalThis.fetch = async () => + new Response(JSON.stringify({ a: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + const client = createClient({ + debug: true, + logger: (e) => stages.push(e.stage), + headers: { Authorization: "Bearer secret-token" }, + jsonSchema: { + "~standard": { + version: 1, + vendor: "test", + validate: (v) => ({ value: v }), + }, + }, + }); + await client.request("http://example.test/data?token=abc", { + unwrapResponse: true, + }); + assert.ok(stages.includes("config")); + assert.ok(stages.includes("init")); + assert.ok(stages.includes("request")); + assert.ok(stages.includes("fetch")); + assert.ok(stages.includes("fetch_complete")); + assert.ok(stages.includes("parse")); + assert.ok(stages.includes("schema")); + assert.ok(stages.includes("response")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("debug verbose + retry: attempt_start, retry, hook_after_response on force retry", async () => { + const originalFetch = globalThis.fetch; + let n = 0; + globalThis.fetch = async () => { + n += 1; + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + try { + const events = []; + const client = createClient({ + debug: "verbose", + logger: (e) => events.push({ stage: e.stage, ...e }), + middlewares: [ + createRetryMiddleware({ + maxAttempts: 3, + baseDelayMs: 1, + maxDelayMs: 5, + onAfterResponse: () => { + if (n === 1) throw new OpenFetchForceRetry(); + }, + }), + ], + unwrapResponse: true, + }); + await client.request("http://example.test/r"); + assert.equal(n, 2); + const stages = events.map((x) => x.stage); + assert.ok(stages.filter((s) => s === "attempt_start").length >= 2); + assert.ok(stages.includes("hook_after_response")); + assert.ok(stages.includes("retry")); + const retryEv = events.find((e) => e.stage === "retry"); + assert.equal(retryEv.reason, "forceRetry"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("debug fetch logs redacted URL and masked authorization", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + try { + let fetchPayload = null; + const client = createClient({ + debug: "verbose", + logger: (e) => { + if (e.stage === "fetch") fetchPayload = e; + }, + headers: { Authorization: "Bearer verysecret" }, + }); + await client.request("http://example.test/z?password=1"); + assert.ok(fetchPayload); + assert.match(fetchPayload.url, /password=\[REDACTED\]|password=%5BREDACTED%5D/); + const auth = fetchPayload.headers?.authorization ?? ""; + assert.ok(!auth.includes("verysecret")); + } finally { + globalThis.fetch = originalFetch; + } +});