UNPKG

apitally

Version:

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

1,536 lines (1,513 loc) 46.8 kB
var __defProp = Object.defineProperty; 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 __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/h3/plugin.ts import { definePlugin, onError, onRequest, onResponse } from "h3"; import { AsyncLocalStorage } from "async_hooks"; import { performance } from "perf_hooks"; // src/common/client.ts import fetchRetry from "fetch-retry"; import { randomUUID as randomUUID3 } from "crypto"; // 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 import { createLogger, format, transports } from "winston"; function getLogger() { return createLogger({ level: process.env.APITALLY_DEBUG ? "debug" : "warn", format: format.combine(format.colorize(), format.timestamp(), format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`)), transports: [ new transports.Console() ] }); } __name(getLogger, "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 import AsyncLock from "async-lock"; import { Buffer as Buffer3 } from "buffer"; import { randomUUID as randomUUID2 } from "crypto"; import { unlinkSync, 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 import { createHash } from "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(); this.sentryEventIds.clear(); return data; } getKey(serverError) { const hashInput = [ serverError.consumer || "", serverError.method.toUpperCase(), serverError.path, serverError.type, serverError.msg.trim(), serverError.traceback.trim() ].join("|"); return 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 import { Buffer as Buffer2 } from "buffer"; import { randomUUID } from "crypto"; import { createWriteStream, readFile } from "fs"; import { unlink } from "fs/promises"; 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(); await 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 = Buffer3.from("<body too large>"); var BODY_MASKED = Buffer3.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 AsyncLock(); 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 _a2; const contentType = (_a2 = headers.find(([k]) => k.toLowerCase() === "content-type")) == null ? void 0 : _a2[1]; return this.isSupportedContentType(contentType); } hasJsonContentType(headers) { var _a2; const contentType = (_a2 = headers.find(([k]) => k.toLowerCase() === "content-type")) == null ? void 0 : _a2[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 = Buffer3.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 _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; } 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: randomUUID2(), 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(Buffer3.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 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 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 = join2(tmpdir2(), `apitally-${randomUUID2()}`); writeFileSync(testPath, "test"); unlinkSync(testPath); return true; } catch (error) { return false; } } __name(checkWritableFs, "checkWritableFs"); // src/common/validationErrorCounter.ts import { createHash as createHash2 } from "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(".").filter(Boolean), 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.split(".").filter(Boolean), validationError.msg.trim(), validationError.type ].join("|"); return createHash2("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", requestLogging, 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)`); } if (requestLoggingConfig && !requestLogging) { console.warn("requestLoggingConfig is deprecated, use requestLogging instead."); } _ApitallyClient.instance = this; this.clientId = clientId; this.env = env; this.instanceUuid = randomUUID3(); this.syncDataQueue = []; this.requestCounter = new RequestCounter(); this.requestLogger = new RequestLogger(requestLogging ?? 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 = fetchRetry(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: randomUUID3(), ...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: randomUUID3(), 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 = fetchRetry(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/headers.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"); function mergeHeaders(base, merge) { const mergedHeaders = new Headers(base); for (const [name, value] of merge) if (name === "set-cookie") mergedHeaders.append(name, value); else mergedHeaders.set(name, value); return mergedHeaders; } __name(mergeHeaders, "mergeHeaders"); // src/common/response.ts async function measureResponseSize(response, tee = true) { const [newResponse1, newResponse2] = tee ? teeResponse(response) : [ response, response ]; let size = 0; if (newResponse2.body) { let done = false; const reader = newResponse2.body.getReader(); while (!done) { const result = await reader.read(); done = result.done; if (!done && result.value) { size += result.value.byteLength; } } } return [ size, newResponse1 ]; } __name(measureResponseSize, "measureResponseSize"); async function getResponseBody(response, tee = true) { const [newResponse1, newResponse2] = tee ? teeResponse(response) : [ response, response ]; const responseBuffer = Buffer.from(await newResponse2.arrayBuffer()); return [ responseBuffer, newResponse1 ]; } __name(getResponseBody, "getResponseBody"); function teeResponse(response) { if (!response.body) { return [ response, response ]; } const [stream1, stream2] = response.body.tee(); const newResponse1 = new Response(stream1, { status: response.status, statusText: response.statusText, headers: response.headers }); const newResponse2 = new Response(stream2, { status: response.status, statusText: response.statusText, headers: response.headers }); return [ newResponse1, newResponse2 ]; } __name(teeResponse, "teeResponse"); // src/loggers/console.ts import { format as format2 } from "util"; var MAX_BUFFER_SIZE = 1e3; var isPatched = false; var globalLogsContext; function patchConsole(logsContext) { globalLogsContext = logsContext; if (isPatched) { return; } const logMethods = [ "log", "warn", "error", "info", "debug" ]; logMethods.forEach((method) => { const originalMethod = console[method]; console[method] = function(...args) { captureLog(method, args); return originalMethod.apply(console, args); }; }); isPatched = true; } __name(patchConsole, "patchConsole"); function captureLog(level, args) { const logs = globalLogsContext == null ? void 0 : globalLogsContext.getStore(); if (logs && logs.length < MAX_BUFFER_SIZE) { logs.push({ timestamp: Date.now() / 1e3, logger: "console", level, message: format2(...args) }); } } __name(captureLog, "captureLog"); // src/loggers/hapi.ts import { format as format3 } from "util"; // src/loggers/utils.ts import { format as format4 } from "util"; function formatMessage(message, ...args) { return [ message, ...args ].map(formatArg).filter((arg) => arg !== "").join("\n"); } __name(formatMessage, "formatMessage"); function removeKeys(obj, keys) { return Object.fromEntries(Object.entries(obj).filter(([key]) => !keys.includes(key))); } __name(removeKeys, "removeKeys"); function formatArg(arg) { if (typeof arg === "string") { return arg.trim(); } if (arg instanceof Error) { return format4(arg).trim(); } if (arg === void 0 || arg === null || isEmptyObject(arg)) { return ""; } try { return JSON.stringify(arg); } catch { return format4(arg).trim(); } } __name(formatArg, "formatArg"); function isEmptyObject(obj) { return obj !== null && typeof obj === "object" && Object.getPrototypeOf(obj) === Object.prototype && Object.keys(obj).length === 0; } __name(isEmptyObject, "isEmptyObject"); // src/loggers/pino.ts var originalStreamSym = Symbol.for("apitally.originalStream"); // src/loggers/winston.ts var MAX_BUFFER_SIZE2 = 1e3; var isPatched2 = false; var globalLogsContext2; async function patchWinston(logsContext) { var _a2, _b; globalLogsContext2 = logsContext; if (isPatched2) { return; } try { const loggerModule = await import("winston/lib/winston/logger.js"); if ((_b = (_a2 = loggerModule.default) == null ? void 0 : _a2.prototype) == null ? void 0 : _b.write) { const originalWrite = loggerModule.default.prototype.write; loggerModule.default.prototype.write = function(info) { captureLog2(info); return originalWrite.call(this, info); }; } } catch { } isPatched2 = true; } __name(patchWinston, "patchWinston"); function captureLog2(info) { const logs = globalLogsContext2 == null ? void 0 : globalLogsContext2.getStore(); if (!logs || !info || logs.length >= MAX_BUFFER_SIZE2) { return; } try { const rest = removeKeys(info, [ "timestamp", "level", "message", "splat" ]); const formattedMessage = formatMessage(info.message, rest); if (formattedMessage) { logs.push({ timestamp: parseTimestamp(info.timestamp), level: info.level || "info", message: formattedMessage.trim() }); } } catch (e) { } } __name(captureLog2, "captureLog"); function parseTimestamp(timestamp) { if (timestamp) { const ts = new Date(timestamp).getTime(); if (!isNaN(ts)) { return ts / 1e3; } } return Date.now() / 1e3; } __name(parseTimestamp, "parseTimestamp"); // src/common/packageVersions.ts import { createRequire } from "module"; function getPackageVersion(name) { const packageJsonPath = `${name}/package.json`; try { return __require(packageJsonPath).version || null; } catch (error) { try { const _require = createRequire(import.meta.url); return _require(packageJsonPath).version || null; } catch (error2) { return null; } } } __name(getPackageVersion, "getPackageVersion"); // src/h3/utils.ts function getAppInfo(h3, appVersion) { const versions = []; if (process.versions.node) { versions.push([ "nodejs", process.versions.node ]); } if (process.versions.bun) { versions.push([ "bun", process.versions.bun ]); } const h3Version = getPackageVersion("h3"); const apitallyVersion = getPackageVersion("../.."); if (h3Version) { versions.push([ "h3", h3Version ]); } if (apitallyVersion) { versions.push([ "apitally", apitallyVersion ]); } if (appVersion) { versions.push([ "app", appVersion ]); } return { paths: h3._routes.map((route) => ({ method: route.method || "", path: route.route || "" })).filter((route) => route.method && route.path && ![ "HEAD", "OPTIONS" ].includes(route.method.toUpperCase())), versions: Object.fromEntries(versions), client: "js:h3" }; } __name(getAppInfo, "getAppInfo"); // src/h3/plugin.ts var REQUEST_TIMESTAMP_SYMBOL = Symbol("apitally.requestTimestamp"); var REQUEST_BODY_SYMBOL = Symbol("apitally.requestBody"); var jsonHeaders = new Headers({ "content-type": "application/json;charset=UTF-8" }); var apitallyPlugin = definePlugin((app, config) => { const client = new ApitallyClient(config); const logsContext = new AsyncLocalStorage(); const setStartupData = /* @__PURE__ */ __name((attempt = 1) => { const appInfo = getAppInfo(app, config.appVersion); if (appInfo.paths.length > 0 || attempt >= 10) { client.setStartupData(appInfo); } else { setTimeout(() => setStartupData(attempt + 1), 500); } }, "setStartupData"); setTimeout(() => setStartupData(), 500); if (client.requestLogger.config.captureLogs) { patchConsole(logsContext); patchWinston(logsContext); } const handleResponse = /* @__PURE__ */ __name(async (event, response, error) => { var _a2, _b; if (event.req.method.toUpperCase() === "OPTIONS") { return response; } const startTime = event.context[REQUEST_TIMESTAMP_SYMBOL]; const responseTime = startTime ? performance.now() - startTime : 0; const path = (_a2 = event.context.matchedRoute) == null ? void 0 : _a2.route; const statusCode = (response == null ? void 0 : response.status) || (error == null ? void 0 : error.status) || 500; const requestSize = parseContentLength(event.req.headers.get("content-length")); let responseSize = 0; let newResponse = response; if (response) { [responseSize, newResponse] = await measureResponseSize(response); } const consumer = getConsumer(event); client.consumerRegistry.addOrUpdateConsumer(consumer); if (path) { client.requestCounter.addRequest({ consumer: consumer == null ? void 0 : consumer.identifier, method: event.req.method, path, statusCode, responseTime, requestSize, responseSize }); if ((error == null ? void 0 : error.status) === 400 && error.data.name === "ZodError") { const zodError = error.data; (_b = zodError.issues) == null ? void 0 : _b.forEach((issue) => { client.validationErrorCounter.addValidationError({ consumer: consumer == null ? void 0 : consumer.identifier, method: event.req.method, path, loc: issue.path.join("."), msg: issue.message, type: issue.code }); }); } if ((error == null ? void 0 : error.status) === 500 && error.cause instanceof Error) { client.serverErrorCounter.addServerError({ consumer: consumer == null ? void 0 : consumer.identifier, method: event.req.method, path, type: error.cause.name, msg: error.cause.message, traceback: error.cause.stack || "" }); } } if (client.requestLogger.enabled) { const responseHeaders = response ? response.headers : (error == null ? void 0 : error.headers) ? mergeHeaders(jsonHeaders, error.headers) : jsonHeaders; const responseContentType = responseHeaders.get("content-type"); let responseBody; if (newResponse && client.requestLogger.config.logResponseBody && client.requestLogger.isSupportedContentType(responseContentType)) { [responseBody, newResponse] = await getResponseBody(newResponse); } else if (error && client.requestLogger.config.logResponseBody) { responseBody = Buffer.from(JSON.stringify(error.toJSON())); } const logs = logsContext.getStore(); client.requestLogger.logRequest({ timestamp: (Date.now() - responseTime) / 1e3, method: event.req.method, path, url: event.req.url, headers: convertHeaders(Object.fromEntries(event.req.headers.entries())), size: requestSize, consumer: consumer == null ? void 0 : consumer.identifier, body: event.context[REQUEST_BODY_SYMBOL] }, { statusCode, responseTime: responseTime / 1e3, headers: convertHeaders(Object.fromEntries(responseHeaders.entries())), size: responseSize, body: responseBody }, (error == null ? void 0 : error.cause) instanceof Error ? error.cause : void 0, logs); } return newResponse; }, "handleResponse"); app.use(onRequest(async (event) => { logsContext.enterWith([]); event.context[REQUEST_TIMESTAMP_SYMBOL] = performance.now(); const requestContentType = event.req.headers.get("content-type"); const requestSize = parseContentLength(event.req.headers.get("content-length")) ?? 0; if (client.requestLogger.enabled && client.requestLogger.config.logRequestBody && client.requestLogger.isSupportedContentType(requestContentType) && requestSize <= client.requestLogger.maxBodySize) { const clonedRequest = event.req.clone(); const requestBody = Buffer.from(await clonedRequest.arrayBuffer()); event.context[REQUEST_BODY_SYMBOL] = requestBody; } })).use(onResponse((response, event) => { if (client.isEnabled()) { return handleResponse(event, response, void 0); } })).use(onError((error, event) => { if (client.isEnabled()) { return handleResponse(event, void 0, error); } })); }); function setConsumer(event, consumer) { event.context.apitallyConsumer = consumer || void 0; } __name(setConsumer, "setConsumer"); function getConsumer(event) { const consumer = event.context.apitallyConsumer; if (consumer) { return consumerFromStringOrObject(consumer); } return null; } __name(getConsumer, "getConsumer"); export { apitallyPlugin, setConsumer }; //# sourceMappingURL=plugin.js.map