UNPKG

apitally

Version:

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

1,494 lines (1,476 loc) 47.9 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 __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; 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); var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/express/index.ts var express_exports = {}; __export(express_exports, { useApitally: () => useApitally }); module.exports = __toCommonJS(express_exports); // src/express/middleware.ts var import_perf_hooks = require("perf_hooks"); // src/common/client.ts var import_crypto5 = require("crypto"); var import_fetch_retry = __toESM(require("fetch-retry"), 1); // src/common/consumerRegistry.ts var consumerFromStringOrObject = /* @__PURE__ */ __name((consumer) => { var _a2, _b; if (typeof consumer === "string") { consumer = String(consumer).trim().substring(0, 128); return consumer ? { identifier: consumer } : null; } else { consumer.identifier = String(consumer.identifier).trim().substring(0, 128); consumer.name = (_a2 = consumer.name) == null ? void 0 : _a2.trim().substring(0, 64); consumer.group = (_b = consumer.group) == null ? void 0 : _b.trim().substring(0, 64); return consumer.identifier ? consumer : null; } }, "consumerFromStringOrObject"); var _ConsumerRegistry = class _ConsumerRegistry { consumers; updated; constructor() { this.consumers = /* @__PURE__ */ new Map(); this.updated = /* @__PURE__ */ new Set(); } addOrUpdateConsumer(consumer) { if (!consumer || !consumer.name && !consumer.group) { return; } const existing = this.consumers.get(consumer.identifier); if (!existing) { this.consumers.set(consumer.identifier, consumer); this.updated.add(consumer.identifier); } else { if (consumer.name && consumer.name !== existing.name) { existing.name = consumer.name; this.updated.add(consumer.identifier); } if (consumer.group && consumer.group !== existing.group) { existing.group = consumer.group; this.updated.add(consumer.identifier); } } } getAndResetUpdatedConsumers() { const data = []; this.updated.forEach((identifier) => { const consumer = this.consumers.get(identifier); if (consumer) { data.push(consumer); } }); this.updated.clear(); return data; } }; __name(_ConsumerRegistry, "ConsumerRegistry"); var ConsumerRegistry = _ConsumerRegistry; // src/common/logging.ts var import_winston = require("winston"); var getLogger = /* @__PURE__ */ __name(() => { return (0, import_winston.createLogger)({ level: process.env.APITALLY_DEBUG ? "debug" : "warn", format: import_winston.format.combine(import_winston.format.colorize(), import_winston.format.timestamp(), import_winston.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`)), transports: [ new import_winston.transports.Console() ] }); }, "getLogger"); // src/common/paramValidation.ts function isValidClientId(clientId) { const regexExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return regexExp.test(clientId); } __name(isValidClientId, "isValidClientId"); function isValidEnv(env) { const regexExp = /^[\w-]{1,32}$/; return regexExp.test(env); } __name(isValidEnv, "isValidEnv"); // src/common/requestCounter.ts var _RequestCounter = class _RequestCounter { requestCounts; requestSizeSums; responseSizeSums; responseTimes; requestSizes; responseSizes; constructor() { this.requestCounts = /* @__PURE__ */ new Map(); this.requestSizeSums = /* @__PURE__ */ new Map(); this.responseSizeSums = /* @__PURE__ */ new Map(); this.responseTimes = /* @__PURE__ */ new Map(); this.requestSizes = /* @__PURE__ */ new Map(); this.responseSizes = /* @__PURE__ */ new Map(); } getKey(requestInfo) { return [ requestInfo.consumer || "", requestInfo.method.toUpperCase(), requestInfo.path, requestInfo.statusCode ].join("|"); } addRequest(requestInfo) { const key = this.getKey(requestInfo); this.requestCounts.set(key, (this.requestCounts.get(key) || 0) + 1); if (!this.responseTimes.has(key)) { this.responseTimes.set(key, /* @__PURE__ */ new Map()); } const responseTimeMap = this.responseTimes.get(key); const responseTimeMsBin = Math.floor(requestInfo.responseTime / 10) * 10; responseTimeMap.set(responseTimeMsBin, (responseTimeMap.get(responseTimeMsBin) || 0) + 1); if (requestInfo.requestSize !== void 0) { requestInfo.requestSize = Number(requestInfo.requestSize); this.requestSizeSums.set(key, (this.requestSizeSums.get(key) || 0) + requestInfo.requestSize); if (!this.requestSizes.has(key)) { this.requestSizes.set(key, /* @__PURE__ */ new Map()); } const requestSizeMap = this.requestSizes.get(key); const requestSizeKbBin = Math.floor(requestInfo.requestSize / 1e3); requestSizeMap.set(requestSizeKbBin, (requestSizeMap.get(requestSizeKbBin) || 0) + 1); } if (requestInfo.responseSize !== void 0) { requestInfo.responseSize = Number(requestInfo.responseSize); this.responseSizeSums.set(key, (this.responseSizeSums.get(key) || 0) + requestInfo.responseSize); if (!this.responseSizes.has(key)) { this.responseSizes.set(key, /* @__PURE__ */ new Map()); } const responseSizeMap = this.responseSizes.get(key); const responseSizeKbBin = Math.floor(requestInfo.responseSize / 1e3); responseSizeMap.set(responseSizeKbBin, (responseSizeMap.get(responseSizeKbBin) || 0) + 1); } } getAndResetRequests() { const data = []; this.requestCounts.forEach((count, key) => { const [consumer, method, path, statusCodeStr] = key.split("|"); const responseTimes = this.responseTimes.get(key) || /* @__PURE__ */ new Map(); const requestSizes = this.requestSizes.get(key) || /* @__PURE__ */ new Map(); const responseSizes = this.responseSizes.get(key) || /* @__PURE__ */ new Map(); data.push({ consumer: consumer || null, method, path, status_code: parseInt(statusCodeStr), request_count: count, request_size_sum: this.requestSizeSums.get(key) || 0, response_size_sum: this.responseSizeSums.get(key) || 0, response_times: Object.fromEntries(responseTimes), request_sizes: Object.fromEntries(requestSizes), response_sizes: Object.fromEntries(responseSizes) }); }); this.requestCounts.clear(); this.requestSizeSums.clear(); this.responseSizeSums.clear(); this.responseTimes.clear(); this.requestSizes.clear(); this.responseSizes.clear(); return data; } }; __name(_RequestCounter, "RequestCounter"); var RequestCounter = _RequestCounter; // src/common/requestLogger.ts var import_async_lock = __toESM(require("async-lock"), 1); var import_buffer2 = require("buffer"); var import_crypto3 = require("crypto"); var import_fs2 = require("fs"); var import_os2 = require("os"); var import_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_crypto = require("crypto"); var MAX_MSG_LENGTH = 2048; var MAX_STACKTRACE_LENGTH = 65536; var _ServerErrorCounter = class _ServerErrorCounter { errorCounts; errorDetails; sentryEventIds; constructor() { this.errorCounts = /* @__PURE__ */ new Map(); this.errorDetails = /* @__PURE__ */ new Map(); this.sentryEventIds = /* @__PURE__ */ new Map(); } addServerError(serverError) { const key = this.getKey(serverError); if (!this.errorDetails.has(key)) { this.errorDetails.set(key, serverError); } this.errorCounts.set(key, (this.errorCounts.get(key) || 0) + 1); const sentryEventId = getSentryEventId(); if (sentryEventId) { this.sentryEventIds.set(key, sentryEventId); } } getAndResetServerErrors() { const data = []; this.errorCounts.forEach((count, key) => { const serverError = this.errorDetails.get(key); if (serverError) { data.push({ consumer: serverError.consumer || null, method: serverError.method, path: serverError.path, type: serverError.type, msg: truncateExceptionMessage(serverError.msg), traceback: truncateExceptionStackTrace(serverError.traceback), sentry_event_id: this.sentryEventIds.get(key) || null, error_count: count }); } }); this.errorCounts.clear(); this.errorDetails.clear(); return data; } getKey(serverError) { const hashInput = [ serverError.consumer || "", serverError.method.toUpperCase(), serverError.path, serverError.type, serverError.msg.trim(), serverError.traceback.trim() ].join("|"); return (0, import_crypto.createHash)("md5").update(hashInput).digest("hex"); } }; __name(_ServerErrorCounter, "ServerErrorCounter"); var ServerErrorCounter = _ServerErrorCounter; 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_buffer = require("buffer"); var import_crypto2 = require("crypto"); var import_fs = require("fs"); var import_os = require("os"); var import_path = require("path"); var import_zlib = require("zlib"); var _TempGzipFile = class _TempGzipFile { uuid; filePath; gzip; writeStream; readyPromise; closedPromise; constructor() { this.uuid = (0, import_crypto2.randomUUID)(); this.filePath = (0, import_path.join)((0, import_os.tmpdir)(), `apitally-${this.uuid}.gz`); this.writeStream = (0, import_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_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_buffer.Buffer.concat([ data, import_buffer.Buffer.from("\n") ]), (error) => { if (error) { reject(error); } else { resolve(); } }); }); } async getContent() { return new Promise((resolve, reject) => { (0, import_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(); (0, import_fs.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 = import_buffer2.Buffer.from("<body too large>"); var BODY_MASKED = import_buffer2.Buffer.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 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); } } 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 _a2; const contentType = (_a2 = headers.find(([k]) => k.toLowerCase() === "content-type")) == null ? void 0 : _a2[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 _a2, _b, _c; if (!this.enabled || this.suspendUntil !== null) return; const url = new URL(request.url); const path = request.path ?? url.pathname; const userAgent = (_a2 = request.headers.find(([k]) => k.toLowerCase() === "user-agent")) == null ? void 0 : _a2[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: (0, import_crypto3.randomUUID)(), 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(import_buffer2.Buffer.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 import_buffer2.Buffer.from(body); } else { return import_buffer2.Buffer.from(JSON.stringify(body)); } } if (contentType.startsWith("text/") && typeof body === "string") { return import_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_buffer2.Buffer.isBuffer(v) || typeof v === "string") { return v.length > 0; } return true; })); } __name(skipEmptyValues, "skipEmptyValues"); function checkWritableFs() { try { const testPath = (0, import_path2.join)((0, import_os2.tmpdir)(), `apitally-${(0, import_crypto3.randomUUID)()}`); (0, import_fs2.writeFileSync)(testPath, "test"); (0, import_fs2.unlinkSync)(testPath); return true; } catch (error) { return false; } } __name(checkWritableFs, "checkWritableFs"); // src/common/validationErrorCounter.ts var import_crypto4 = require("crypto"); var _ValidationErrorCounter = class _ValidationErrorCounter { errorCounts; errorDetails; constructor() { this.errorCounts = /* @__PURE__ */ new Map(); this.errorDetails = /* @__PURE__ */ new Map(); } addValidationError(validationError) { const key = this.getKey(validationError); if (!this.errorDetails.has(key)) { this.errorDetails.set(key, validationError); } this.errorCounts.set(key, (this.errorCounts.get(key) || 0) + 1); } getAndResetValidationErrors() { const data = []; this.errorCounts.forEach((count, key) => { const validationError = this.errorDetails.get(key); if (validationError) { data.push({ consumer: validationError.consumer || null, method: validationError.method, path: validationError.path, loc: validationError.loc.split("."), msg: validationError.msg, type: validationError.type, error_count: count }); } }); this.errorCounts.clear(); this.errorDetails.clear(); return data; } getKey(validationError) { const hashInput = [ validationError.consumer || "", validationError.method.toUpperCase(), validationError.path, validationError.loc, validationError.msg.trim(), validationError.type ].join("|"); return (0, import_crypto4.createHash)("md5").update(hashInput).digest("hex"); } }; __name(_ValidationErrorCounter, "ValidationErrorCounter"); var ValidationErrorCounter = _ValidationErrorCounter; // src/common/client.ts var SYNC_INTERVAL = 6e4; var INITIAL_SYNC_INTERVAL = 1e4; var INITIAL_SYNC_INTERVAL_DURATION = 36e5; var MAX_QUEUE_TIME = 36e5; var _a; var HTTPError = (_a = class extends Error { response; constructor(response) { const reason = response.status ? `status code ${response.status}` : "an unknown error"; super(`Request failed with ${reason}`); this.response = response; } }, __name(_a, "HTTPError"), _a); var _ApitallyClient = class _ApitallyClient { clientId; env; instanceUuid; syncDataQueue; syncIntervalId; startupData; startupDataSent = false; enabled = true; requestCounter; requestLogger; validationErrorCounter; serverErrorCounter; consumerRegistry; logger; constructor({ clientId, env = "dev", requestLoggingConfig, logger }) { if (_ApitallyClient.instance) { throw new Error("Apitally client is already initialized"); } if (!isValidClientId(clientId)) { throw new Error(`Invalid Apitally client ID '${clientId}' (expecting hexadecimal UUID format)`); } if (!isValidEnv(env)) { throw new Error(`Invalid env '${env}' (expecting 1-32 alphanumeric lowercase characters and hyphens only)`); } _ApitallyClient.instance = this; this.clientId = clientId; this.env = env; this.instanceUuid = (0, import_crypto5.randomUUID)(); this.syncDataQueue = []; this.requestCounter = new RequestCounter(); this.requestLogger = new RequestLogger(requestLoggingConfig); this.validationErrorCounter = new ValidationErrorCounter(); this.serverErrorCounter = new ServerErrorCounter(); this.consumerRegistry = new ConsumerRegistry(); this.logger = logger || getLogger(); this.startSync(); this.handleShutdown = this.handleShutdown.bind(this); } static getInstance() { if (!_ApitallyClient.instance) { throw new Error("Apitally client is not initialized"); } return _ApitallyClient.instance; } isEnabled() { return this.enabled; } static async shutdown() { if (_ApitallyClient.instance) { await _ApitallyClient.instance.handleShutdown(); } } async handleShutdown() { this.enabled = false; this.stopSync(); await this.sendSyncData(); await this.sendLogData(); await this.requestLogger.close(); _ApitallyClient.instance = void 0; } getHubUrlPrefix() { const baseURL = process.env.APITALLY_HUB_BASE_URL || "https://hub.apitally.io"; const version = "v2"; return `${baseURL}/${version}/${this.clientId}/${this.env}/`; } async sendData(url, payload) { const fetchWithRetry = (0, import_fetch_retry.default)(fetch, { retries: 3, retryDelay: 1e3, retryOn: [ 408, 429, 500, 502, 503, 504 ] }); const response = await fetchWithRetry(this.getHubUrlPrefix() + url, { method: "POST", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } }); if (!response.ok) { throw new HTTPError(response); } } startSync() { this.sync(); this.syncIntervalId = setInterval(() => { this.sync(); }, INITIAL_SYNC_INTERVAL); setTimeout(() => { clearInterval(this.syncIntervalId); this.syncIntervalId = setInterval(() => { this.sync(); }, SYNC_INTERVAL); }, INITIAL_SYNC_INTERVAL_DURATION); } async sync() { try { const promises = [ this.sendSyncData(), this.sendLogData() ]; if (!this.startupDataSent) { promises.push(this.sendStartupData()); } await Promise.all(promises); } catch (error) { this.logger.error("Error while syncing with Apitally Hub", { error }); } } stopSync() { if (this.syncIntervalId) { clearInterval(this.syncIntervalId); this.syncIntervalId = void 0; } } setStartupData(data) { this.startupData = data; this.startupDataSent = false; this.sendStartupData(); } async sendStartupData() { if (this.startupData) { this.logger.debug("Sending startup data to Apitally Hub"); const payload = { instance_uuid: this.instanceUuid, message_uuid: (0, import_crypto5.randomUUID)(), ...this.startupData }; try { await this.sendData("startup", payload); this.startupDataSent = true; } catch (error) { const handled = this.handleHubError(error); if (!handled) { this.logger.error(error.message); this.logger.debug("Error while sending startup data to Apitally Hub (will retry)", { error }); } } } } async sendSyncData() { this.logger.debug("Synchronizing data with Apitally Hub"); const newPayload = { timestamp: Date.now() / 1e3, instance_uuid: this.instanceUuid, message_uuid: (0, import_crypto5.randomUUID)(), requests: this.requestCounter.getAndResetRequests(), validation_errors: this.validationErrorCounter.getAndResetValidationErrors(), server_errors: this.serverErrorCounter.getAndResetServerErrors(), consumers: this.consumerRegistry.getAndResetUpdatedConsumers() }; this.syncDataQueue.push(newPayload); let i = 0; while (this.syncDataQueue.length > 0) { const payload = this.syncDataQueue.shift(); if (payload) { try { if (Date.now() - payload.timestamp * 1e3 <= MAX_QUEUE_TIME) { if (i > 0) { await this.randomDelay(); } await this.sendData("sync", payload); i += 1; } } catch (error) { const handled = this.handleHubError(error); if (!handled) { this.logger.debug("Error while synchronizing data with Apitally Hub (will retry)", { error }); this.syncDataQueue.push(payload); break; } } } } } async sendLogData() { this.logger.debug("Sending request log data to Apitally Hub"); await this.requestLogger.rotateFile(); const fetchWithRetry = (0, import_fetch_retry.default)(fetch, { retries: 3, retryDelay: 1e3, retryOn: [ 408, 429, 500, 502, 503, 504 ] }); let i = 0; let logFile; while (logFile = this.requestLogger.getFile()) { if (i > 0) { await this.randomDelay(); } try { const response = await fetchWithRetry(`${this.getHubUrlPrefix()}log?uuid=${logFile.uuid}`, { method: "POST", body: await logFile.getContent() }); if (response.status === 402 && response.headers.has("Retry-After")) { const retryAfter = parseInt(response.headers.get("Retry-After") ?? "0"); if (retryAfter > 0) { this.requestLogger.suspendUntil = Date.now() + retryAfter * 1e3; this.requestLogger.clear(); return; } } if (!response.ok) { throw new HTTPError(response); } logFile.delete(); } catch (error) { this.requestLogger.retryFileLater(logFile); break; } i++; if (i >= 10) break; } } handleHubError(error) { if (error instanceof HTTPError) { if (error.response.status === 404) { this.logger.error(`Invalid Apitally client ID: '${this.clientId}'`); this.enabled = false; this.stopSync(); return true; } if (error.response.status === 422) { this.logger.error("Received validation error from Apitally Hub"); return true; } } return false; } async randomDelay() { const delay = 100 + Math.random() * 400; await new Promise((resolve) => setTimeout(resolve, delay)); } }; __name(_ApitallyClient, "ApitallyClient"); __publicField(_ApitallyClient, "instance"); var ApitallyClient = _ApitallyClient; // src/common/packageVersions.ts var import_module = require("module"); var import_meta = {}; function getPackageVersion(name) { const packageJsonPath = `${name}/package.json`; try { return require(packageJsonPath).version || null; } catch (error) { try { const _require = (0, import_module.createRequire)(import_meta.url); return _require(packageJsonPath).version || null; } catch (error2) { return null; } } } __name(getPackageVersion, "getPackageVersion"); // src/common/utils.ts function parseContentLength(contentLength) { if (contentLength === void 0 || contentLength === null) { return void 0; } if (typeof contentLength === "number") { return contentLength; } if (typeof contentLength === "string") { const parsed = parseInt(contentLength); return isNaN(parsed) ? void 0 : parsed; } if (Array.isArray(contentLength)) { return parseContentLength(contentLength[0]); } return void 0; } __name(parseContentLength, "parseContentLength"); // src/express/utils.js var regExpToParseExpressPathRegExp = /^\/\^\\?\/?(?:(:?[\w\\.-]*(?:\\\/:?[\w\\.-]*)*)|(\(\?:\\?\/?\([^)]+\)\)))\\\/.*/; var regExpToReplaceExpressPathRegExpParams = /\(\?:\\?\/?\([^)]+\)\)/; var regexpExpressParamRegexp = /\(\?:\\?\\?\/?\([^)]+\)\)/g; var regexpExpressPathParamRegexp = /(:[^)]+)\([^)]+\)/g; var EXPRESS_ROOT_PATH_REGEXP_VALUE = "/^\\/?(?=\\/|$)/i"; var STACK_ITEM_VALID_NAMES = [ "router", "bound dispatch", "mounted_app" ]; var getRouteMethods = /* @__PURE__ */ __name(function(route) { let methods = Object.keys(route.methods); methods = methods.filter((method) => method !== "_all"); methods = methods.map((method) => method.toUpperCase()); return methods; }, "getRouteMethods"); var getRouteMiddlewares = /* @__PURE__ */ __name(function(route) { return route.stack.map((item) => { return item.handle.name || "anonymous"; }); }, "getRouteMiddlewares"); var hasParams = /* @__PURE__ */ __name(function(expressPathRegExp) { return regexpExpressParamRegexp.test(expressPathRegExp); }, "hasParams"); var parseExpressRoute = /* @__PURE__ */ __name(function(route, basePath) { const paths = []; if (Array.isArray(route.path)) { paths.push(...route.path); } else { paths.push(route.path); } const endpoints = paths.map((path) => { const completePath = basePath && path === "/" ? basePath : `${basePath}${path}`; const endpoint = { path: completePath.replace(regexpExpressPathParamRegexp, "$1"), methods: getRouteMethods(route), middlewares: getRouteMiddlewares(route) }; return endpoint; }); return endpoints; }, "parseExpressRoute"); var parseExpressPath = /* @__PURE__ */ __name(function(expressPathRegExp, params) { let parsedRegExp = expressPathRegExp.toString(); let expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp); let paramIndex = 0; while (hasParams(parsedRegExp)) { const paramName = params[paramIndex].name; const paramId = `:${paramName}`; parsedRegExp = parsedRegExp.replace(regExpToReplaceExpressPathRegExpParams, (str) => { if (str.startsWith("(?:\\/")) { return `\\/${paramId}`; } return paramId; }); paramIndex++; } if (parsedRegExp !== expressPathRegExp.toString()) { expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp); } const parsedPath = expressPathRegExpExec[1].replace(/\\\//g, "/"); return parsedPath; }, "parseExpressPath"); var parseEndpoints = /* @__PURE__ */ __name(function(app, basePath, endpoints) { const stack = app.stack || app._router && app._router.stack; endpoints = endpoints || []; basePath = basePath || ""; if (!stack) { if (endpoints.length) { endpoints = addEndpoints(endpoints, [ { path: basePath, methods: [], middlewares: [] } ]); } } else { endpoints = parseStack(stack, basePath, endpoints); } return endpoints; }, "parseEndpoints"); var addEndpoints = /* @__PURE__ */ __name(function(currentEndpoints, endpointsToAdd) { endpointsToAdd.forEach((newEndpoint) => { const existingEndpoint = currentEndpoints.find((endpoint) => endpoint.path === newEndpoint.path); if (existingEndpoint !== void 0) { const newMethods = newEndpoint.methods.filter((method) => !existingEndpoint.methods.includes(method)); existingEndpoint.methods = existingEndpoint.methods.concat(newMethods); } else { currentEndpoints.push(newEndpoint); } }); return currentEndpoints; }, "addEndpoints"); var parseStack = /* @__PURE__ */ __name(function(stack, basePath, endpoints) { stack.forEach((stackItem) => { if (stackItem.route) { const newEndpoints = parseExpressRoute(stackItem.route, basePath); endpoints = addEndpoints(endpoints, newEndpoints); } else if (STACK_ITEM_VALID_NAMES.includes(stackItem.name)) { const isExpressPathRegexp = regExpToParseExpressPathRegExp.test(stackItem.regexp); let newBasePath = basePath; if (isExpressPathRegexp) { const parsedPath = parseExpressPath(stackItem.regexp, stackItem.keys); newBasePath += `/${parsedPath}`; } else if (!stackItem.path && stackItem.regexp && stackItem.regexp.toString() !== EXPRESS_ROOT_PATH_REGEXP_VALUE) { const regExpPath = ` RegExp(${stackItem.regexp}) `; newBasePath += `/${regExpPath}`; } endpoints = parseEndpoints(stackItem.handle, newBasePath, endpoints); } }); return endpoints; }, "parseStack"); var getEndpoints = /* @__PURE__ */ __name(function(app, basePath) { const endpoints = parseEndpoints(app); return endpoints.flatMap((route) => route.methods.filter((method) => ![ "HEAD", "OPTIONS" ].includes(method.toUpperCase())).map((method) => ({ method, path: (basePath + route.path).replace(/\/\//g, "/") }))); }, "getEndpoints"); // src/express/middleware.ts var useApitally = /* @__PURE__ */ __name((app, config) => { const client = new ApitallyClient(config); const middleware = getMiddleware(app, client); app.use(middleware); setTimeout(() => { client.setStartupData(getAppInfo(app, config.basePath, config.appVersion)); }, 1e3); }, "useApitally"); var getMiddleware = /* @__PURE__ */ __name((app, client) => { let errorHandlerConfigured = false; return (req, res, next) => { if (!client.isEnabled()) { next(); return; } if (!errorHandlerConfigured) { app.use((err, req2, res2, next2) => { res2.locals.serverError = err; next2(err); }); errorHandlerConfigured = true; } try { const startTime = import_perf_hooks.performance.now(); const originalSend = res.send; res.send = (body) => { const contentType = res.get("content-type"); if (client.requestLogger.isSupportedContentType(contentType)) { res.locals.body = body; } return originalSend.call(res, body); }; res.on("finish", () => { try { const responseTime = import_perf_hooks.performance.now() - startTime; const path = getRoutePath(req); const consumer = getConsumer(req); client.consumerRegistry.addOrUpdateConsumer(consumer); const requestSize = parseContentLength(req.get("content-length")); const responseSize = parseContentLength(res.get("content-length")); if (path) { client.requestCounter.addRequest({ consumer: consumer == null ? void 0 : consumer.identifier, method: req.method, path, statusCode: res.statusCode, responseTime, requestSize, responseSize }); if ((res.statusCode === 400 || res.statusCode === 422) && res.locals.body) { let jsonBody; try { jsonBody = JSON.parse(res.locals.body); } catch { } if (jsonBody) { const validationErrors = []; if (validationErrors.length === 0) { validationErrors.push(...extractExpressValidatorErrors(jsonBody)); } if (validationErrors.length === 0) { validationErrors.push(...extractCelebrateErrors(jsonBody)); } if (validationErrors.length === 0) { validationErrors.push(...extractNestValidationErrors(jsonBody)); } validationErrors.forEach((error) => { client.validationErrorCounter.addValidationError({ consumer: consumer == null ? void 0 : consumer.identifier, method: req.method, path: req.route.path, ...error }); }); } } if (res.statusCode === 500 && res.locals.serverError) { const serverError = res.locals.serverError; client.serverErrorCounter.addServerError({ consumer: consumer == null ? void 0 : consumer.identifier, method: req.method, path: req.route.path, type: serverError.name, msg: serverError.message, traceback: serverError.stack || "" }); } } if (client.requestLogger.enabled) { client.requestLogger.logRequest({ timestamp: Date.now() / 1e3, method: req.method, path, url: `${req.protocol}://${req.host}${req.originalUrl}`, headers: convertHeaders(req.headers), size: requestSize, consumer: consumer == null ? void 0 : consumer.identifier, body: convertBody(req.body, req.get("content-type")) }, { statusCode: res.statusCode, responseTime: responseTime / 1e3, headers: convertHeaders(res.getHeaders()), size: responseSize, body: convertBody(res.locals.body, res.get("content-type")) }, res.locals.serverError); } } catch (error) { client.logger.error("Error while logging request in Apitally middleware.", { request: req, response: res, error }); } }); } catch (error) { client.logger.error("Error in Apitally middleware.", { request: req, response: res, error }); } finally { next(); } }; }, "getMiddleware"); var getRoutePath = /* @__PURE__ */ __name((req) => { if (!req.route) { return; } if (req.baseUrl) { const routerPath = getRouterPath(req.app._router.stack, req.baseUrl); return req.route.path === "/" ? routerPath : routerPath + req.route.path; } return req.route.path; }, "getRoutePath"); var getRouterPath = /* @__PURE__ */ __name((stack, baseUrl) => { var _a2; const routerPaths = []; while (stack && stack.length > 0) { const routerLayer = stack.find((layer) => { var _a3; return layer.name === "router" && layer.path && ((_a3 = layer.regexp) == null ? void 0 : _a3.test(baseUrl)); }); if (routerLayer) { if (routerLayer.keys.length > 0) { const parsedPath = parseExpressPath(routerLayer.regexp, routerLayer.keys); routerPaths.push("/" + parsedPath); } else { routerPaths.push(routerLayer.path); } stack = (_a2 = routerLayer.handle) == null ? void 0 : _a2.stack; baseUrl = baseUrl.slice(routerLayer.path.length); } else { break; } } return routerPaths.filter((path) => path !== "/").join(""); }, "getRouterPath"); var getConsumer = /* @__PURE__ */ __name((req) => { if (req.apitallyConsumer) { return consumerFromStringOrObject(req.apitallyConsumer); } else if (req.consumerIdentifier) { process.emitWarning("The consumerIdentifier property on the request object is deprecated. Use apitallyConsumer instead.", "DeprecationWarning"); return consumerFromStringOrObject(req.consumerIdentifier); } return null; }, "getConsumer"); var extractExpressValidatorErrors = /* @__PURE__ */ __name((responseBody) => { try { const errors = []; if (responseBody && responseBody.errors && Array.isArray(responseBody.errors)) { responseBody.errors.forEach((error) => { if (error.location && error.path && error.msg && error.type) { errors.push({ loc: `${error.location}.${error.path}`, msg: error.msg, type: error.type }); } }); } return errors; } catch (error) { return []; } }, "extractExpressValidatorErrors"); var extractCelebrateErrors = /* @__PURE__ */ __name((responseBody) => { try { const errors = []; if (responseBody && responseBody.validation) { Object.values(responseBody.validation).forEach((error) => { if (error.source && error.keys && Array.isArray(error.keys) && error.message) { error.keys.forEach((key) => { errors.push({ loc: `${error.source}.${key}`, msg: subsetJoiMessage(error.message, key), type: "" }); }); } }); } return errors; } catch (error) { return []; } }, "extractCelebrateErrors"); var extractNestValidationErrors = /* @__PURE__ */ __name((responseBody) => { try { const errors = []; if (responseBody && Array.isArray(responseBody.message)) { responseBody.message.forEach((message) => { errors.push({ loc: "", msg: message, type: "" }); }); } return errors; } catch (error) { return []; } }, "extractNestValidationErrors"); var subsetJoiMessage = /* @__PURE__ */ __name((message, key) => { const messageWithKey = message.split(". ").find((message2) => message2.includes(`"${key}"`)); return messageWithKey ? messageWithKey : message; }, "subsetJoiMessage"); var getAppInfo = /* @__PURE__ */ __name((app, basePath, appVersion) => { const versions = [ [ "nodejs", process.version.replace(/^v/, "") ] ]; const expressVersion = getPackageVersion("express"); const nestjsVersion = getPackageVersion("@nestjs/core"); const apitallyVersion = getPackageVersion("../.."); if (expressVersion) { versions.push([ "express", expressVersion ]); } if (nestjsVersion) { versions.push([ "nestjs", nestjsVersion ]); } if (apitallyVersion) { versions.push([ "apitally", apitallyVersion ]); } if (appVersion) { versions.push([ "app", appVersion ]); } return { paths: getEndpoints(app, basePath || ""), versions: Object.fromEntries(versions), client: "js:express" }; }, "getAppInfo"); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { useApitally }); //# sourceMappingURL=index.cjs.map