UNPKG

apitally

Version:

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

609 lines (604 loc) 18.1 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/common/requestLogger.ts var requestLogger_exports = {}; __export(requestLogger_exports, { convertBody: () => convertBody, convertHeaders: () => convertHeaders, default: () => RequestLogger }); module.exports = __toCommonJS(requestLogger_exports); var import_async_lock = __toESM(require("async-lock"), 1); var import_node_buffer2 = require("buffer"); var import_node_crypto3 = require("crypto"); var import_node_fs2 = require("fs"); var import_node_os2 = require("os"); var import_node_path2 = require("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 import_node_crypto = require("crypto"); 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 var import_node_buffer = require("buffer"); var import_node_crypto2 = require("crypto"); var import_node_fs = require("fs"); var import_promises = require("fs/promises"); var import_node_os = require("os"); var import_node_path = require("path"); var import_node_zlib = require("zlib"); var _TempGzipFile = class _TempGzipFile { uuid; filePath; gzip; writeStream; readyPromise; closedPromise; constructor() { this.uuid = (0, import_node_crypto2.randomUUID)(); this.filePath = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `apitally-${this.uuid}.gz`); this.writeStream = (0, import_node_fs.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 = (0, import_node_zlib.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(import_node_buffer.Buffer.concat([ data, import_node_buffer.Buffer.from("\n") ]), (error) => { if (error) { reject(error); } else { resolve(); } }); }); } async getContent() { return new Promise((resolve, reject) => { (0, import_node_fs.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(); await (0, import_promises.unlink)(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 MAX_LOG_MSG_LENGTH = 2048; var BODY_TOO_LARGE = import_node_buffer2.Buffer.from("<body too large>"); var BODY_MASKED = import_node_buffer2.Buffer.from("<masked>"); var MASKED = "******"; var ALLOWED_CONTENT_TYPES = [ "application/json", "application/problem+json", "application/vnd.api+json", "text/plain", "text/html" ]; 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 MASK_BODY_FIELD_PATTERNS = [ /password/i, /pwd/i, /token/i, /secret/i, /auth/i, /card[-_ ]?number/i, /ccv/i, /ssn/i ]; var DEFAULT_CONFIG = { enabled: false, logQueryParams: true, logRequestHeaders: false, logRequestBody: false, logResponseHeaders: true, logResponseBody: false, logException: true, captureLogs: false, maskQueryParams: [], maskHeaders: [], maskBodyFields: [], excludePaths: [] }; var _RequestLogger = class _RequestLogger { config; enabled; suspendUntil = null; pendingWrites = []; currentFile = null; files = []; maintainIntervalId; lock = new import_async_lock.default(); constructor(config) { this.config = { ...DEFAULT_CONFIG, ...config }; this.enabled = this.config.enabled && checkWritableFs(); if (this.enabled) { this.maintainIntervalId = setInterval(() => { this.maintain(); }, 1e3); } } get maxBodySize() { return MAX_BODY_SIZE; } 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); } shouldMaskBodyField(name) { const patterns = [ ...this.config.maskBodyFields, ...MASK_BODY_FIELD_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); } hasJsonContentType(headers) { var _a; const contentType = (_a = headers.find(([k]) => k.toLowerCase() === "content-type")) == null ? void 0 : _a[1]; return contentType ? /\bjson\b/i.test(contentType) : null; } 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 ]); } maskBody(data) { if (typeof data === "object" && data !== null && !Array.isArray(data)) { const result = {}; for (const [key, value] of Object.entries(data)) { if (typeof value === "string" && this.shouldMaskBodyField(key)) { result[key] = MASKED; } else { result[key] = this.maskBody(value); } } return result; } if (Array.isArray(data)) { return data.map((item) => this.maskBody(item)); } return data; } applyMasking(item) { if (this.config.maskRequestBodyCallback && item.request.body && item.request.body !== BODY_TOO_LARGE) { try { const maskedBody = this.config.maskRequestBodyCallback(item.request); item.request.body = maskedBody ?? BODY_MASKED; } catch { item.request.body = void 0; } } if (this.config.maskResponseBodyCallback && item.response.body && item.response.body !== BODY_TOO_LARGE) { try { const maskedBody = this.config.maskResponseBodyCallback(item.request, item.response); item.response.body = maskedBody ?? BODY_MASKED; } catch { item.response.body = void 0; } } if (item.request.body && item.request.body.length > MAX_BODY_SIZE) { item.request.body = BODY_TOO_LARGE; } if (item.response.body && item.response.body.length > MAX_BODY_SIZE) { item.response.body = BODY_TOO_LARGE; } for (const key of [ "request", "response" ]) { const bodyData = item[key].body; if (!bodyData || bodyData === BODY_TOO_LARGE || bodyData === BODY_MASKED) { continue; } const headers = item[key].headers; const hasJsonContent = this.hasJsonContentType(headers); if (hasJsonContent === null || hasJsonContent) { try { const parsedBody = JSON.parse(bodyData.toString()); const maskedBody = this.maskBody(parsedBody); item[key].body = import_node_buffer2.Buffer.from(JSON.stringify(maskedBody)); } catch { } } } item.request.headers = this.config.logRequestHeaders ? this.maskHeaders(item.request.headers) : []; item.response.headers = this.config.logResponseHeaders ? this.maskHeaders(item.response.headers) : []; const url = new URL(item.request.url); url.search = this.config.logQueryParams ? this.maskQueryParams(url.search) : ""; item.request.url = url.toString(); return item; } logRequest(request, response, error, logs) { 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; } if (!this.config.logRequestBody || !this.hasSupportedContentType(request.headers)) { request.body = void 0; } if (!this.config.logResponseBody || !this.hasSupportedContentType(response.headers)) { response.body = void 0; } if (request.size !== void 0 && request.size < 0) { request.size = void 0; } if (response.size !== void 0 && response.size < 0) { response.size = void 0; } const item = { uuid: (0, import_node_crypto3.randomUUID)(), request, response, exception: error && this.config.logException ? { type: error.name, message: truncateExceptionMessage(error.message), stacktrace: truncateExceptionStackTrace(error.stack || ""), sentryEventId: getSentryEventId() } : void 0 }; if (logs && logs.length > 0) { item.logs = logs.map((log) => ({ timestamp: log.timestamp, logger: log.logger, level: log.level, message: truncateLogMessage(log.message) })); } this.pendingWrites.push(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) { let item = this.pendingWrites.shift(); if (item) { item = this.applyMasking(item); const finalItem = { uuid: item.uuid, request: skipEmptyValues(item.request), response: skipEmptyValues(item.response), exception: item.exception, logs: item.logs }; [ finalItem.request.body, finalItem.response.body ].forEach((body) => { if (body) { body.toJSON = function() { return this.toString("base64"); }; } }); await this.currentFile.writeLine(import_node_buffer2.Buffer.from(JSON.stringify(finalItem))); } } }); } 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) { return []; } 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 import_node_buffer2.Buffer.from(body); } else { return import_node_buffer2.Buffer.from(JSON.stringify(body)); } } if (contentType.startsWith("text/") && typeof body === "string") { return import_node_buffer2.Buffer.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) || import_node_buffer2.Buffer.isBuffer(v) || typeof v === "string") { return v.length > 0; } return true; })); } __name(skipEmptyValues, "skipEmptyValues"); function truncateLogMessage(msg) { if (msg.length > MAX_LOG_MSG_LENGTH) { const suffix = "... (truncated)"; return msg.slice(0, MAX_LOG_MSG_LENGTH - suffix.length) + suffix; } return msg; } __name(truncateLogMessage, "truncateLogMessage"); function checkWritableFs() { try { const testPath = (0, import_node_path2.join)((0, import_node_os2.tmpdir)(), `apitally-${(0, import_node_crypto3.randomUUID)()}`); (0, import_node_fs2.writeFileSync)(testPath, "test"); (0, import_node_fs2.unlinkSync)(testPath); return true; } catch (error) { return false; } } __name(checkWritableFs, "checkWritableFs"); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { convertBody, convertHeaders }); //# sourceMappingURL=requestLogger.cjs.map