apitally
Version:
Simple API monitoring & analytics for REST APIs built with Express, Fastify, Hono, Koa, and NestJS.
1,464 lines (1,447 loc) • 46.1 kB
JavaScript
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/express/middleware.ts
import { performance } from "perf_hooks";
// src/common/client.ts
import { randomUUID as randomUUID3 } from "crypto";
import fetchRetry from "fetch-retry";
// 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";
var getLogger = /* @__PURE__ */ __name(() => {
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()
]
});
}, "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 as unlinkSync2, writeFileSync } from "fs";
import { tmpdir as tmpdir2 } from "os";
import { join as join2 } from "path";
// src/common/sentry.ts
var sentry;
(async () => {
try {
sentry = await import("@sentry/node");
} catch (e) {
}
})();
function getSentryEventId() {
if (sentry && sentry.lastEventId) {
return sentry.lastEventId();
}
return void 0;
}
__name(getSentryEventId, "getSentryEventId");
// src/common/serverErrorCounter.ts
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();
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, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { createGzip } from "zlib";
var _TempGzipFile = class _TempGzipFile {
uuid;
filePath;
gzip;
writeStream;
readyPromise;
closedPromise;
constructor() {
this.uuid = randomUUID();
this.filePath = join(tmpdir(), `apitally-${this.uuid}.gz`);
this.writeStream = createWriteStream(this.filePath);
this.readyPromise = new Promise((resolve, reject) => {
this.writeStream.once("ready", resolve);
this.writeStream.once("error", reject);
});
this.closedPromise = new Promise((resolve, reject) => {
this.writeStream.once("close", resolve);
this.writeStream.once("error", reject);
});
this.gzip = createGzip();
this.gzip.pipe(this.writeStream);
}
get size() {
return this.writeStream.bytesWritten;
}
async writeLine(data) {
await this.readyPromise;
return new Promise((resolve, reject) => {
this.gzip.write(Buffer2.concat([
data,
Buffer2.from("\n")
]), (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
async getContent() {
return new Promise((resolve, reject) => {
readFile(this.filePath, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data);
}
});
});
}
async close() {
await new Promise((resolve) => {
this.gzip.end(() => {
resolve();
});
});
await this.closedPromise;
}
async delete() {
await this.close();
unlinkSync(this.filePath);
}
};
__name(_TempGzipFile, "TempGzipFile");
var TempGzipFile = _TempGzipFile;
// src/common/requestLogger.ts
var MAX_BODY_SIZE = 5e4;
var MAX_FILE_SIZE = 1e6;
var MAX_FILES = 50;
var MAX_PENDING_WRITES = 100;
var BODY_TOO_LARGE = Buffer3.from("<body too large>");
var BODY_MASKED = Buffer3.from("<masked>");
var MASKED = "******";
var ALLOWED_CONTENT_TYPES = [
"application/json",
"text/plain"
];
var EXCLUDE_PATH_PATTERNS = [
/\/_?healthz?$/i,
/\/_?health[_-]?checks?$/i,
/\/_?heart[_-]?beats?$/i,
/\/ping$/i,
/\/ready$/i,
/\/live$/i
];
var EXCLUDE_USER_AGENT_PATTERNS = [
/health[-_ ]?check/i,
/microsoft-azure-application-lb/i,
/googlehc/i,
/kube-probe/i
];
var MASK_QUERY_PARAM_PATTERNS = [
/auth/i,
/api-?key/i,
/secret/i,
/token/i,
/password/i,
/pwd/i
];
var MASK_HEADER_PATTERNS = [
/auth/i,
/api-?key/i,
/secret/i,
/token/i,
/cookie/i
];
var DEFAULT_CONFIG = {
enabled: false,
logQueryParams: true,
logRequestHeaders: false,
logRequestBody: false,
logResponseHeaders: true,
logResponseBody: false,
logException: true,
maskQueryParams: [],
maskHeaders: [],
excludePaths: []
};
var _RequestLogger = class _RequestLogger {
config;
enabled;
suspendUntil = null;
pendingWrites = [];
currentFile = null;
files = [];
maintainIntervalId;
lock = new AsyncLock();
constructor(config) {
this.config = {
...DEFAULT_CONFIG,
...config
};
this.enabled = this.config.enabled && checkWritableFs();
if (this.enabled) {
this.maintainIntervalId = setInterval(() => {
this.maintain();
}, 1e3);
}
}
shouldExcludePath(urlPath) {
const patterns = [
...this.config.excludePaths,
...EXCLUDE_PATH_PATTERNS
];
return matchPatterns(urlPath, patterns);
}
shouldExcludeUserAgent(userAgent) {
return userAgent ? matchPatterns(userAgent, EXCLUDE_USER_AGENT_PATTERNS) : false;
}
shouldMaskQueryParam(name) {
const patterns = [
...this.config.maskQueryParams,
...MASK_QUERY_PARAM_PATTERNS
];
return matchPatterns(name, patterns);
}
shouldMaskHeader(name) {
const patterns = [
...this.config.maskHeaders,
...MASK_HEADER_PATTERNS
];
return matchPatterns(name, patterns);
}
hasSupportedContentType(headers) {
var _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: randomUUID2(),
request: skipEmptyValues(request),
response: skipEmptyValues(response),
exception: error && this.config.logException ? {
type: error.name,
message: truncateExceptionMessage(error.message),
stacktrace: truncateExceptionStackTrace(error.stack || ""),
sentryEventId: getSentryEventId()
} : null
};
[
item.request.body,
item.response.body
].forEach((body) => {
if (body) {
body.toJSON = function() {
return this.toString("base64");
};
}
});
this.pendingWrites.push(JSON.stringify(item));
if (this.pendingWrites.length > MAX_PENDING_WRITES) {
this.pendingWrites.shift();
}
}
async writeToFile() {
if (!this.enabled || this.pendingWrites.length === 0) {
return;
}
return this.lock.acquire("file", async () => {
if (!this.currentFile) {
this.currentFile = new TempGzipFile();
}
while (this.pendingWrites.length > 0) {
const item = this.pendingWrites.shift();
if (item) {
await this.currentFile.writeLine(Buffer3.from(item));
}
}
});
}
getFile() {
return this.files.shift();
}
retryFileLater(file) {
this.files.unshift(file);
}
async rotateFile() {
return this.lock.acquire("file", async () => {
if (this.currentFile) {
await this.currentFile.close();
this.files.push(this.currentFile);
this.currentFile = null;
}
});
}
async maintain() {
await this.writeToFile();
if (this.currentFile && this.currentFile.size > MAX_FILE_SIZE) {
await this.rotateFile();
}
while (this.files.length > MAX_FILES) {
const file = this.files.shift();
file == null ? void 0 : file.delete();
}
if (this.suspendUntil !== null && this.suspendUntil < Date.now()) {
this.suspendUntil = null;
}
}
async clear() {
this.pendingWrites = [];
await this.rotateFile();
this.files.forEach((file) => {
file.delete();
});
this.files = [];
}
async close() {
this.enabled = false;
await this.clear();
if (this.maintainIntervalId) {
clearInterval(this.maintainIntervalId);
}
}
};
__name(_RequestLogger, "RequestLogger");
var RequestLogger = _RequestLogger;
function convertHeaders(headers) {
if (headers instanceof Headers) {
return Array.from(headers.entries());
}
return Object.entries(headers).flatMap(([key, value]) => {
if (value === void 0) {
return [];
}
if (Array.isArray(value)) {
return value.map((v) => [
key,
v
]);
}
return [
[
key,
value.toString()
]
];
});
}
__name(convertHeaders, "convertHeaders");
function convertBody(body, contentType) {
if (!body || !contentType) {
return;
}
try {
if (contentType.startsWith("application/json")) {
if (isValidJsonString(body)) {
return Buffer3.from(body);
} else {
return Buffer3.from(JSON.stringify(body));
}
}
if (contentType.startsWith("text/") && typeof body === "string") {
return Buffer3.from(body);
}
} catch (error) {
return;
}
}
__name(convertBody, "convertBody");
function isValidJsonString(body) {
if (typeof body !== "string") {
return false;
}
try {
JSON.parse(body);
return true;
} catch {
return false;
}
}
__name(isValidJsonString, "isValidJsonString");
function matchPatterns(value, patterns) {
return patterns.some((pattern) => {
return pattern.test(value);
});
}
__name(matchPatterns, "matchPatterns");
function skipEmptyValues(data) {
return Object.fromEntries(Object.entries(data).filter(([_, v]) => {
if (v == null || Number.isNaN(v)) return false;
if (Array.isArray(v) || Buffer3.isBuffer(v) || typeof v === "string") {
return v.length > 0;
}
return true;
}));
}
__name(skipEmptyValues, "skipEmptyValues");
function checkWritableFs() {
try {
const testPath = join2(tmpdir2(), `apitally-${randomUUID2()}`);
writeFileSync(testPath, "test");
unlinkSync2(testPath);
return true;
} catch (error) {
return false;
}
}
__name(checkWritableFs, "checkWritableFs");
// 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("."),
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 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", 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 = randomUUID3();
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 = 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/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/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 = 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 = 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");
export {
useApitally
};
//# sourceMappingURL=middleware.js.map