UNPKG

apitally

Version:

Simple API monitoring & analytics for REST APIs built with Express, Fastify, Hono, Koa, and NestJS.

473 lines (468 loc) 12.9 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/common/requestLogger.ts import AsyncLock from "async-lock"; import { Buffer as Buffer3 } from "buffer"; import { randomUUID as randomUUID2 } from "crypto"; import { unlinkSync as unlinkSync2, writeFileSync } from "fs"; import { tmpdir as tmpdir2 } from "os"; import { join as join2 } from "path"; // src/common/sentry.ts var sentry; (async () => { try { sentry = await import("@sentry/node"); } catch (e) { } })(); function getSentryEventId() { if (sentry && sentry.lastEventId) { return sentry.lastEventId(); } return void 0; } __name(getSentryEventId, "getSentryEventId"); // src/common/serverErrorCounter.ts var MAX_MSG_LENGTH = 2048; var MAX_STACKTRACE_LENGTH = 65536; function truncateExceptionMessage(msg) { if (msg.length <= MAX_MSG_LENGTH) { return msg; } const suffix = "... (truncated)"; const cutoff = MAX_MSG_LENGTH - suffix.length; return msg.substring(0, cutoff) + suffix; } __name(truncateExceptionMessage, "truncateExceptionMessage"); function truncateExceptionStackTrace(stack) { const suffix = "... (truncated) ..."; const cutoff = MAX_STACKTRACE_LENGTH - suffix.length; const lines = stack.trim().split("\n"); const truncatedLines = []; let length = 0; for (const line of lines) { if (length + line.length + 1 > cutoff) { truncatedLines.push(suffix); break; } truncatedLines.push(line); length += line.length + 1; } return truncatedLines.join("\n"); } __name(truncateExceptionStackTrace, "truncateExceptionStackTrace"); // src/common/tempGzipFile.ts import { Buffer as Buffer2 } from "buffer"; import { randomUUID } from "crypto"; import { createWriteStream, readFile, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { createGzip } from "zlib"; var _TempGzipFile = class _TempGzipFile { uuid; filePath; gzip; writeStream; readyPromise; closedPromise; constructor() { this.uuid = randomUUID(); this.filePath = join(tmpdir(), `apitally-${this.uuid}.gz`); this.writeStream = createWriteStream(this.filePath); this.readyPromise = new Promise((resolve, reject) => { this.writeStream.once("ready", resolve); this.writeStream.once("error", reject); }); this.closedPromise = new Promise((resolve, reject) => { this.writeStream.once("close", resolve); this.writeStream.once("error", reject); }); this.gzip = createGzip(); this.gzip.pipe(this.writeStream); } get size() { return this.writeStream.bytesWritten; } async writeLine(data) { await this.readyPromise; return new Promise((resolve, reject) => { this.gzip.write(Buffer2.concat([ data, Buffer2.from("\n") ]), (error) => { if (error) { reject(error); } else { resolve(); } }); }); } async getContent() { return new Promise((resolve, reject) => { readFile(this.filePath, (error, data) => { if (error) { reject(error); } else { resolve(data); } }); }); } async close() { await new Promise((resolve) => { this.gzip.end(() => { resolve(); }); }); await this.closedPromise; } async delete() { await this.close(); unlinkSync(this.filePath); } }; __name(_TempGzipFile, "TempGzipFile"); var TempGzipFile = _TempGzipFile; // src/common/requestLogger.ts var MAX_BODY_SIZE = 5e4; var MAX_FILE_SIZE = 1e6; var MAX_FILES = 50; var MAX_PENDING_WRITES = 100; var BODY_TOO_LARGE = Buffer3.from("<body too large>"); var BODY_MASKED = Buffer3.from("<masked>"); var MASKED = "******"; var ALLOWED_CONTENT_TYPES = [ "application/json", "text/plain" ]; var EXCLUDE_PATH_PATTERNS = [ /\/_?healthz?$/i, /\/_?health[_-]?checks?$/i, /\/_?heart[_-]?beats?$/i, /\/ping$/i, /\/ready$/i, /\/live$/i ]; var EXCLUDE_USER_AGENT_PATTERNS = [ /health[-_ ]?check/i, /microsoft-azure-application-lb/i, /googlehc/i, /kube-probe/i ]; var MASK_QUERY_PARAM_PATTERNS = [ /auth/i, /api-?key/i, /secret/i, /token/i, /password/i, /pwd/i ]; var MASK_HEADER_PATTERNS = [ /auth/i, /api-?key/i, /secret/i, /token/i, /cookie/i ]; var DEFAULT_CONFIG = { enabled: false, logQueryParams: true, logRequestHeaders: false, logRequestBody: false, logResponseHeaders: true, logResponseBody: false, logException: true, maskQueryParams: [], maskHeaders: [], excludePaths: [] }; var _RequestLogger = class _RequestLogger { config; enabled; suspendUntil = null; pendingWrites = []; currentFile = null; files = []; maintainIntervalId; lock = new AsyncLock(); constructor(config) { this.config = { ...DEFAULT_CONFIG, ...config }; this.enabled = this.config.enabled && checkWritableFs(); if (this.enabled) { this.maintainIntervalId = setInterval(() => { this.maintain(); }, 1e3); } } shouldExcludePath(urlPath) { const patterns = [ ...this.config.excludePaths, ...EXCLUDE_PATH_PATTERNS ]; return matchPatterns(urlPath, patterns); } shouldExcludeUserAgent(userAgent) { return userAgent ? matchPatterns(userAgent, EXCLUDE_USER_AGENT_PATTERNS) : false; } shouldMaskQueryParam(name) { const patterns = [ ...this.config.maskQueryParams, ...MASK_QUERY_PARAM_PATTERNS ]; return matchPatterns(name, patterns); } shouldMaskHeader(name) { const patterns = [ ...this.config.maskHeaders, ...MASK_HEADER_PATTERNS ]; return matchPatterns(name, patterns); } hasSupportedContentType(headers) { var _a; const contentType = (_a = headers.find(([k]) => k.toLowerCase() === "content-type")) == null ? void 0 : _a[1]; return this.isSupportedContentType(contentType); } isSupportedContentType(contentType) { return typeof contentType === "string" && ALLOWED_CONTENT_TYPES.some((t) => contentType.startsWith(t)); } maskQueryParams(search) { const params = new URLSearchParams(search); for (const [key] of params) { if (this.shouldMaskQueryParam(key)) { params.set(key, MASKED); } } return params.toString(); } maskHeaders(headers) { return headers.map(([k, v]) => [ k, this.shouldMaskHeader(k) ? MASKED : v ]); } logRequest(request, response, error) { var _a, _b, _c; if (!this.enabled || this.suspendUntil !== null) return; const url = new URL(request.url); const path = request.path ?? url.pathname; const userAgent = (_a = request.headers.find(([k]) => k.toLowerCase() === "user-agent")) == null ? void 0 : _a[1]; if (this.shouldExcludePath(path) || this.shouldExcludeUserAgent(userAgent) || (((_c = (_b = this.config).excludeCallback) == null ? void 0 : _c.call(_b, request, response)) ?? false)) { return; } url.search = this.config.logQueryParams ? this.maskQueryParams(url.search) : ""; request.url = url.toString(); if (!this.config.logRequestBody || !this.hasSupportedContentType(request.headers)) { request.body = void 0; } else if (request.body) { if (request.body.length > MAX_BODY_SIZE) { request.body = BODY_TOO_LARGE; } else if (this.config.maskRequestBodyCallback) { try { request.body = this.config.maskRequestBodyCallback(request) ?? BODY_MASKED; if (request.body.length > MAX_BODY_SIZE) { request.body = BODY_TOO_LARGE; } } catch { request.body = void 0; } } } if (!this.config.logResponseBody || !this.hasSupportedContentType(response.headers)) { response.body = void 0; } else if (response.body) { if (response.body.length > MAX_BODY_SIZE) { response.body = BODY_TOO_LARGE; } else if (this.config.maskResponseBodyCallback) { try { response.body = this.config.maskResponseBodyCallback(request, response) ?? BODY_MASKED; if (response.body.length > MAX_BODY_SIZE) { response.body = BODY_TOO_LARGE; } } catch { response.body = void 0; } } } request.headers = this.config.logRequestHeaders ? this.maskHeaders(request.headers) : []; response.headers = this.config.logResponseHeaders ? this.maskHeaders(response.headers) : []; const item = { uuid: randomUUID2(), request: skipEmptyValues(request), response: skipEmptyValues(response), exception: error && this.config.logException ? { type: error.name, message: truncateExceptionMessage(error.message), stacktrace: truncateExceptionStackTrace(error.stack || ""), sentryEventId: getSentryEventId() } : null }; [ item.request.body, item.response.body ].forEach((body) => { if (body) { body.toJSON = function() { return this.toString("base64"); }; } }); this.pendingWrites.push(JSON.stringify(item)); if (this.pendingWrites.length > MAX_PENDING_WRITES) { this.pendingWrites.shift(); } } async writeToFile() { if (!this.enabled || this.pendingWrites.length === 0) { return; } return this.lock.acquire("file", async () => { if (!this.currentFile) { this.currentFile = new TempGzipFile(); } while (this.pendingWrites.length > 0) { const item = this.pendingWrites.shift(); if (item) { await this.currentFile.writeLine(Buffer3.from(item)); } } }); } getFile() { return this.files.shift(); } retryFileLater(file) { this.files.unshift(file); } async rotateFile() { return this.lock.acquire("file", async () => { if (this.currentFile) { await this.currentFile.close(); this.files.push(this.currentFile); this.currentFile = null; } }); } async maintain() { await this.writeToFile(); if (this.currentFile && this.currentFile.size > MAX_FILE_SIZE) { await this.rotateFile(); } while (this.files.length > MAX_FILES) { const file = this.files.shift(); file == null ? void 0 : file.delete(); } if (this.suspendUntil !== null && this.suspendUntil < Date.now()) { this.suspendUntil = null; } } async clear() { this.pendingWrites = []; await this.rotateFile(); this.files.forEach((file) => { file.delete(); }); this.files = []; } async close() { this.enabled = false; await this.clear(); if (this.maintainIntervalId) { clearInterval(this.maintainIntervalId); } } }; __name(_RequestLogger, "RequestLogger"); var RequestLogger = _RequestLogger; function convertHeaders(headers) { if (headers instanceof Headers) { return Array.from(headers.entries()); } return Object.entries(headers).flatMap(([key, value]) => { if (value === void 0) { return []; } if (Array.isArray(value)) { return value.map((v) => [ key, v ]); } return [ [ key, value.toString() ] ]; }); } __name(convertHeaders, "convertHeaders"); function convertBody(body, contentType) { if (!body || !contentType) { return; } try { if (contentType.startsWith("application/json")) { if (isValidJsonString(body)) { return Buffer3.from(body); } else { return Buffer3.from(JSON.stringify(body)); } } if (contentType.startsWith("text/") && typeof body === "string") { return Buffer3.from(body); } } catch (error) { return; } } __name(convertBody, "convertBody"); function isValidJsonString(body) { if (typeof body !== "string") { return false; } try { JSON.parse(body); return true; } catch { return false; } } __name(isValidJsonString, "isValidJsonString"); function matchPatterns(value, patterns) { return patterns.some((pattern) => { return pattern.test(value); }); } __name(matchPatterns, "matchPatterns"); function skipEmptyValues(data) { return Object.fromEntries(Object.entries(data).filter(([_, v]) => { if (v == null || Number.isNaN(v)) return false; if (Array.isArray(v) || Buffer3.isBuffer(v) || typeof v === "string") { return v.length > 0; } return true; })); } __name(skipEmptyValues, "skipEmptyValues"); function checkWritableFs() { try { const testPath = join2(tmpdir2(), `apitally-${randomUUID2()}`); writeFileSync(testPath, "test"); unlinkSync2(testPath); return true; } catch (error) { return false; } } __name(checkWritableFs, "checkWritableFs"); export { convertBody, convertHeaders, RequestLogger as default }; //# sourceMappingURL=requestLogger.js.map