apitally
Version:
Simple API monitoring & analytics for REST APIs built with Express, Fastify, NestJS, AdonisJS, Hono, H3, Elysia, Hapi, and Koa.
1,565 lines (1,542 loc) • 49 kB
JavaScript
"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/h3/plugin.ts
var plugin_exports = {};
__export(plugin_exports, {
apitallyPlugin: () => apitallyPlugin,
setConsumer: () => setConsumer
});
module.exports = __toCommonJS(plugin_exports);
var import_h3 = require("h3");
var import_node_async_hooks = require("async_hooks");
var import_node_perf_hooks = require("perf_hooks");
// src/common/client.ts
var import_fetch_retry = __toESM(require("fetch-retry"), 1);
var import_node_crypto5 = require("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
var import_winston = require("winston");
function getLogger() {
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()
]
});
}
__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
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;
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 (0, import_node_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_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 _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 = 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 _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: (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 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");
// src/common/validationErrorCounter.ts
var import_node_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(".").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 (0, import_node_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", 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 = (0, import_node_crypto5.randomUUID)();
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 = (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_node_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_node_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/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
var import_node_util = require("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: (0, import_node_util.format)(...args)
});
}
}
__name(captureLog, "captureLog");
// src/loggers/hapi.ts
var import_node_util2 = require("util");
// src/loggers/utils.ts
var import_node_util3 = require("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 (0, import_node_util3.format)(arg).trim();
}
if (arg === void 0 || arg === null || isEmptyObject(arg)) {
return "";
}
try {
return JSON.stringify(arg);
} catch {
return (0, import_node_util3.format)(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
var import_node_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_node_module.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 = (0, import_h3.definePlugin)((app, config) => {
const client = new ApitallyClient(config);
const logsContext = new import_node_async_hooks.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 ? import_node_perf_hooks.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((0, import_h3.onRequest)(async (event) => {
logsContext.enterWith([]);
event.context[REQUEST_TIMESTAMP_SYMBOL] = import_node_perf_hooks.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((0, import_h3.onResponse)((response, event) => {
if (client.isEnabled()) {
return handleResponse(event, response, void 0);
}
})).use((0, import_h3.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");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
apitallyPlugin,
setConsumer
});
//# sourceMappingURL=plugin.cjs.map