@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
318 lines • 15 kB
JavaScript
// packages/core/src/http/http-client.watcher.ts
import { Logger } from '@nestjs/common';
import { EntryType } from '../entry/entry.js';
import { normalizeHttpTarget } from '../query/normalize-route.js';
import { isCustomAxiosSource, } from './axios-source.js';
/** Query-param keys whose VALUES are redacted from a captured URL (key-based
* content redaction can't reach a URL string leaf, so we sanitize here). */
const SENSITIVE_PARAM = /(token|secret|password|passwd|api[-_]?key|access[-_]?key|auth|credential)/i;
const REDACTED = '[REDACTED]';
/** Marks a wrapped `fetch` so we patch the global exactly once. `Symbol.for`
* (global registry) is deliberate: if two copies of core load, both see the
* same marker and neither double-wraps the other's wrapper. */
const PATCHED = Symbol.for('@dudousxd/nestjs-telescope:httpClientPatched');
/** Axios instances already wired by ANY watcher in this module copy. Kept
* module-level (not per-watcher) so that two `HttpClientWatcher`s sharing one
* axios instance — or the same watcher registered twice — never install
* duplicate interceptors and double-record. Mirrors the cross-instance
* idempotency of the cache watcher's `Symbol.for` marker, but as a `WeakSet`
* so we don't mutate the host's object. */
const INSTRUMENTED_AXIOS = new WeakSet();
/**
* Sanitize a raw URL string for storage: strip userinfo (`user:pass@`) and
* redact sensitive query-param VALUES, then return `{ url, host }`. A relative
* or otherwise unparseable URL is kept verbatim with `host: null`.
*
* Extracted so the `fetch` and axios paths sanitize IDENTICALLY — both must
* never leak credentials/secrets, and downstream entries must be
* indistinguishable. Key-based content redaction can't reach a URL string leaf,
* which is why this URL-level pass exists.
*/
function sanitizeUrl(rawUrl) {
try {
const parsed = new URL(rawUrl);
const host = parsed.host;
parsed.username = '';
parsed.password = '';
for (const key of [...parsed.searchParams.keys()]) {
if (SENSITIVE_PARAM.test(key))
parsed.searchParams.set(key, REDACTED);
}
return { url: parsed.toString(), host };
}
catch {
// Relative/invalid URL: keep the raw string, no host.
return { url: rawUrl, host: null };
}
}
/** Pull method/url/host out of fetch's (input, init) without throwing. The
* returned `url` is sanitized via {@link sanitizeUrl}: userinfo stripped and
* sensitive query-param values redacted, so credentials/secrets never reach
* storage. */
function describeRequest(input, init) {
let rawUrl = '';
let method = 'GET';
if (typeof input === 'string')
rawUrl = input;
else if (input instanceof URL)
rawUrl = input.href;
else if (input instanceof Request) {
rawUrl = input.url;
method = input.method;
}
if (init && typeof init.method === 'string')
method = init.method;
const { url, host } = sanitizeUrl(rawUrl);
return { method: method.toUpperCase(), url, host };
}
/** True for a plain object literal (`{}` / `Object.create(null)`) — the only
* `params` shape we serialize. Arrays, `URLSearchParams`, class instances, etc.
* are skipped rather than guessing axios's paramsSerializer semantics. */
function isPlainObject(value) {
if (typeof value !== 'object' || value === null)
return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/**
* Reconstruct method/url/host from an axios request config, mirroring fetch's
* {@link describeRequest} so axios and fetch entries are indistinguishable.
*
* The full URL is `baseURL` joined to `url` (axios resolves relative `url`
* against `baseURL`). Plain-object `params` are appended as a conservative
* query string; any other `params` shape (URLSearchParams, arrays, custom
* serializers) is skipped — we never reimplement axios's serializer. The result
* runs through {@link sanitizeUrl} for the same redaction as fetch.
*/
function describeAxiosRequest(config) {
const method = (config.method ?? 'GET').toUpperCase();
const base = config.baseURL ?? '';
const path = config.url ?? '';
// Join baseURL + url the way axios does: absolute `url` wins; otherwise
// concatenate, collapsing a duplicated slash at the seam.
let rawUrl;
if (base && path && !/^https?:\/\//i.test(path)) {
rawUrl = `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
}
else {
rawUrl = path || base;
}
if (isPlainObject(config.params)) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(config.params)) {
if (value === undefined || value === null)
continue;
search.append(key, String(value));
}
const qs = search.toString();
if (qs)
rawUrl += (rawUrl.includes('?') ? '&' : '?') + qs;
}
const { url, host } = sanitizeUrl(rawUrl);
return { method, url, host };
}
/** Narrows an unknown rejection (axios passes `unknown` through interceptors)
* to the {@link AxiosErrorLike} fields we read. Always returns an object so the
* caller can read optional `config`/`response` without a cast. A real axios
* error carries `config` and (on an HTTP response) `response.status`; a
* transport failure has no `response`, which the caller records as `null`. */
function asAxiosError(error) {
if (typeof error !== 'object' || error === null)
return {};
const candidate = error;
const result = {};
// `config` only needs to be a non-null object — every field we read off it is
// optional, so we don't probe further.
if (typeof candidate.config === 'object' && candidate.config !== null) {
result.config = candidate.config;
}
const response = candidate.response;
if (typeof response === 'object' &&
response !== null &&
'status' in response &&
typeof response.status === 'number') {
result.response = { status: response.status };
}
return result;
}
/**
* Captures outbound HTTP calls made via the global `fetch` and, optionally, via
* an axios instance (`@nestjs/axios`'s `HttpService.axiosRef` or a bare axios),
* correlated to the request/job that made them.
*
* Both paths run inside the caller's async context, so the active ALS batch
* (set by the request middleware) is live when we record — no extra wiring is
* needed for correlation. Recording is guarded so a telescope failure can never
* change the host's HTTP result, and the host's network error is always
* re-thrown. The `fetch` patch is idempotent across instances via a symbol
* marker; axios instrumentation is idempotent via a module-level `WeakSet` of
* instrumented instances (so two watchers sharing one axios don't double-wrap).
*
* ## Why axios needs its own path
* NestJS apps mostly call out through `@nestjs/axios`. In Node, axios uses the
* http adapter (not `fetch`), so those calls bypass the `fetch` patch entirely
* and are invisible without this. We attach via axios's PUBLIC interceptor API
* (`interceptors.request/response.use`) — never monkey-patching — so we don't
* fight axios internals or other interceptors the host installs.
*
* @remarks
* Only the global `fetch` and an explicitly-provided axios instance are
* instrumented. Clients that bypass both (a custom `http.request`, native
* addons) are not captured. The global `fetch` is replaced for the process
* lifetime (not restored on shutdown) — appropriate for an always-on
* observability tool. The captured URL has userinfo stripped and sensitive
* query-param values redacted; key-based content redaction does not otherwise
* apply to the URL string.
*
* **Double-capture (edge case):** if an axios instance is explicitly configured
* with a `fetch` adapter AND that `fetch` is our patched global, a single call
* could record twice (once per path). Node's default is the http adapter, so
* this only happens when a host opts into the fetch adapter; we don't build
* detection machinery for it.
*/
export class HttpClientWatcher {
type = EntryType.HttpClient;
logger = new Logger(HttpClientWatcher.name);
slowMs;
clock;
axiosSource;
/** Per-request start times, keyed by the axios config object. A `WeakMap`
* (not a symbol stamped onto the config) keeps the host's object pristine and
* lets entries be GC'd if a request never completes — no leak, no mutation. */
axiosStartedAt = new WeakMap();
constructor(options = {}) {
this.slowMs = options.slowMs ?? 1000;
this.clock = options.clock ?? { now: () => Date.now() };
this.axiosSource = options.axios;
}
register(ctx) {
this.patchFetch(ctx);
this.registerAxios(ctx);
}
/** Patch the global `fetch` exactly as before — unchanged behavior. Idempotent
* process-wide via the {@link PATCHED} marker. Pulled into its own method so
* axios instrumentation can run independently (and still attach even when
* `fetch` is missing or already patched). */
patchFetch(ctx) {
const current = globalThis.fetch;
if (typeof current !== 'function') {
this.logger.warn('HttpClientWatcher: global fetch is unavailable; outbound calls will not be captured.');
return;
}
if (current[PATCHED])
return;
const watcher = this;
const patched = async function patchedFetch(input, init) {
const startedAt = watcher.clock.now();
const { method, url, host } = describeRequest(input, init);
try {
const response = await current(input, init);
watcher.safeRecord(ctx, {
method,
url,
host,
statusCode: response.status,
durationMs: watcher.clock.now() - startedAt,
});
return response;
}
catch (error) {
watcher.safeRecord(ctx, {
method,
url,
host,
statusCode: null,
durationMs: watcher.clock.now() - startedAt,
});
throw error; // never swallow the host's network error
}
};
patched[PATCHED] = true;
globalThis.fetch = patched;
}
/** Wire axios capture if an axios source was provided. The bare-instance form
* attaches immediately; the {@link CustomAxiosSource} form hands the host an
* `attach` callback so it can resolve `HttpService` from `ctx.moduleRef`
* lazily (the instance often doesn't exist yet at construction time). */
registerAxios(ctx) {
const source = this.axiosSource;
if (source === undefined)
return;
if (isCustomAxiosSource(source)) {
source.instrument((instance) => this.attachAxios(instance, ctx), ctx);
return;
}
this.attachAxios(source, ctx);
}
/** Install request/response interceptors on an axios instance via its public
* API. Idempotent via the module-level {@link INSTRUMENTED_AXIOS} set: a
* second call (re-registration, or two watchers sharing one instance) is a
* no-op so no call double-records.
*
* Timing uses {@link axiosStartedAt} — a `WeakMap<config, number>` — rather
* than stamping the config: axios passes the SAME config object from the
* request interceptor through to the response/error interceptor, so the
* config is a stable key, and a `WeakMap` neither mutates the host's object
* nor leaks if a request never resolves. */
attachAxios(instance, ctx) {
if (INSTRUMENTED_AXIOS.has(instance))
return;
INSTRUMENTED_AXIOS.add(instance);
instance.interceptors.request.use((config) => {
this.axiosStartedAt.set(config, this.clock.now());
return config;
});
instance.interceptors.response.use((response) => {
this.recordAxios(ctx, response.config, response.status);
return response;
}, (error) => {
const axiosError = asAxiosError(error);
this.recordAxios(ctx, axiosError.config, axiosError.response?.status ?? null);
// Re-throw so the host's error handling is untouched — axios expects a
// rejected promise from a response interceptor's onRejected.
return Promise.reject(error);
});
}
/** Compute duration from the stored start time, describe the target, and hand
* the entry to {@link safeRecord} — the same content/tags/family-hash shape as
* the `fetch` path, so axios and fetch entries are indistinguishable. A
* missing start time (interceptor order, or a config we never saw) records
* `durationMs: 0` rather than dropping the entry. */
recordAxios(ctx, config, statusCode) {
const safeConfig = config ?? {};
const startedAt = config ? this.axiosStartedAt.get(config) : undefined;
if (config)
this.axiosStartedAt.delete(config);
const durationMs = startedAt === undefined ? 0 : Math.max(0, this.clock.now() - startedAt);
const { method, url, host } = describeAxiosRequest(safeConfig);
this.safeRecord(ctx, { method, url, host, statusCode, durationMs });
}
/** Hand an entry to the Recorder, swallowing any failure so a telescope bug
* can never alter the host's HTTP call. */
safeRecord(ctx, content) {
try {
const tags = [];
if (content.host)
tags.push(`host:${content.host}`);
// A 4xx is a valid response, not a transport failure — only 5xx / network errors are 'failed'.
if (content.statusCode === null || content.statusCode >= 500)
tags.push('failed');
if (content.durationMs >= this.slowMs)
tags.push('slow');
ctx.record({
type: EntryType.HttpClient,
content,
// Group by method + host + normalized path so the same external endpoint
// aggregates regardless of ids — the pulse slow-outgoing hotspot key/label.
familyHash: normalizeHttpTarget(content.method, content.url),
durationMs: content.durationMs,
...(tags.length > 0 ? { tags } : {}),
});
}
catch (recordError) {
const message = recordError instanceof Error ? recordError.message : String(recordError);
this.logger.error(`HttpClientWatcher: failed to record entry: ${message}`);
}
}
}
//# sourceMappingURL=http-client.watcher.js.map