treblle
Version:
Treblle Javascript SDK
912 lines (821 loc) • 25.6 kB
JavaScript
const { maskSensitiveValues } = require("./maskFields");
const os = require("os");
const stackTrace = require("stack-trace");
const VERSION = require("../package.json").version;
/**
* Treblle API payload version - this should be incremented whenever the payload structure changes
* to ensure backward compatibility with older SDKs.
* The current version is 20 which would map to 2.0
*/
const PAYLOAD_VERSION = 20;
const http = require("http");
const https = require("https");
// Try to import node-fetch (only needed in Node.js environments)
let nodeFetch = null;
try {
nodeFetch = require("node-fetch");
} catch (error) {
// node-fetch not available, will use runtime fetch detection
}
// Try to import Hono route helpers (optional dependency)
let honoRouteHelpers = null;
try {
honoRouteHelpers = require("hono/route");
} catch (error) {
// Hono route helpers not available, will use fallback methods
}
// Cache expensive operations at module load
const CACHED_OS_INFO = {
name: os.platform(),
release: os.release(),
architecture: os.arch(),
};
const CACHED_NODE_VERSION = process.version;
const CACHED_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
// HTTP Agent with connection pooling and keep-alive
const HTTP_AGENT = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30000, // 30 seconds
maxSockets: 10,
maxFreeSockets: 5,
timeout: 5000, // 5 second socket timeout
});
const HTTPS_AGENT = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
maxSockets: 10,
maxFreeSockets: 5,
timeout: 5000,
});
// Treblle API endpoints for load balancing
const TREBLLE_ENDPOINTS = [
"https://rocknrolla.treblle.com",
"https://sicario.treblle.com",
"https://punisher.treblle.com",
];
function getRandomEndpoint() {
const randomIndex = Math.floor(Math.random() * TREBLLE_ENDPOINTS.length);
return TREBLLE_ENDPOINTS[randomIndex];
}
// Cache for timestamps to reduce Date object creation
let lastTimestamp = null;
let lastTimestampTime = 0;
const TIMESTAMP_CACHE_MS = 1000; // Cache for 1 second
function getCachedTimestamp() {
const now = Date.now();
if (!lastTimestamp || now - lastTimestampTime > TIMESTAMP_CACHE_MS) {
lastTimestamp = new Date().toISOString().replace("T", " ").substring(0, 19);
lastTimestampTime = now;
}
return lastTimestamp;
}
// Payload size limits and helpers
const MAX_PAYLOAD_SIZE = 5 * 1024 * 1024; // 5MB in bytes
const PAYLOAD_TOO_LARGE_MESSAGE = {
message: "Treblle only captures requests and responses up to 5MB",
actual_size_bytes: null,
};
function estimateObjectSize(obj, visited = new WeakSet()) {
if (!obj || typeof obj !== "object" || visited.has(obj)) return 0;
visited.add(obj);
let size = 0;
if (Array.isArray(obj)) {
for (const item of obj) {
if (typeof item === "string") {
size += Buffer.byteLength(item, "utf8");
} else if (typeof item === "object") {
size += estimateObjectSize(item, visited);
} else {
size += 8; // rough estimate for primitives
}
// Early exit if we exceed limit
if (size > MAX_PAYLOAD_SIZE) return size;
}
} else {
for (const [key, value] of Object.entries(obj)) {
size += Buffer.byteLength(key, "utf8"); // key size
if (typeof value === "string") {
size += Buffer.byteLength(value, "utf8");
} else if (typeof value === "object") {
size += estimateObjectSize(value, visited);
} else {
size += 8; // rough estimate for primitives
}
// Early exit if we exceed limit
if (size > MAX_PAYLOAD_SIZE) return size;
}
}
return size;
}
function getPayloadSize(payload) {
if (!payload) return 0;
if (typeof payload === "string") {
return Buffer.byteLength(payload, "utf8");
}
if (Buffer.isBuffer(payload)) {
return payload.length;
}
// Fast recursive estimation without full serialization
return estimateObjectSize(payload);
}
function checkPayloadSize(payload) {
const size = getPayloadSize(payload);
if (size > MAX_PAYLOAD_SIZE) {
return {
...PAYLOAD_TOO_LARGE_MESSAGE,
actual_size_bytes: size,
};
}
return payload;
}
/**
* Prepares the payload which is sent to Treblle.
*
* @param {object} Express request object
* @param {object} Express response object
* @param {object} settings
* @param {string} settings.sdkToken Treblle SDK token
* @param {string} settings.apiKey Treblle API key
* @param {number[]} settings.requestStartTime when the request started
* @param {object} settings.fieldsToMaskMap map of fields to mask
*/
const generateTrebllePayload = function (
req,
res,
{ sdkToken, apiKey, requestStartTime, error, fieldsToMaskMap, sdk = "express" }
) {
const payload = req.method === "GET" ? req.query : req.body;
const parsedPayload = getPayload(payload);
const sizeCheckedPayload = checkPayloadSize(parsedPayload);
const maskedRequestPayload = maskSensitiveValues(
sizeCheckedPayload,
fieldsToMaskMap
);
const responseHeaders = res.getHeaders();
let errors = [];
// We should be able to parse this, but you never know if users will try doing something weird...
let maskedResponseBody;
let parsedResponseBody;
try {
let originalResponseBody = res.__treblle_body_response;
// if the response is streamed it could be a buffer
// so we'll convert it to a string first
if (Buffer.isBuffer(originalResponseBody)) {
originalResponseBody = originalResponseBody.toString("utf8");
}
if (typeof originalResponseBody === "string") {
parsedResponseBody = JSON.parse(originalResponseBody);
const sizeCheckedResponseBody = checkPayloadSize(parsedResponseBody);
maskedResponseBody = maskSensitiveValues(
sizeCheckedResponseBody,
fieldsToMaskMap
);
} else if (typeof originalResponseBody === "object") {
const sizeCheckedResponseBody = checkPayloadSize(originalResponseBody);
maskedResponseBody = maskSensitiveValues(
sizeCheckedResponseBody,
fieldsToMaskMap
);
}
} catch {
// if we can't parse the body we'll leave it empty and set an error
errors.push({
source: "onShutdown",
type: "INVALID_JSON",
message: "Invalid JSON format",
file: null,
line: null,
});
}
const protocol = `${req.protocol.toUpperCase()}/${req.httpVersion}`;
// get rid of the workaround body, we don't need it anymore
delete res.__treblle_body_response;
if (error) {
const trace = stackTrace.parse(error);
errors.push({
source: "onException",
type: "UNHANDLED_EXCEPTION",
message: error.message,
file: trace[0].getFileName(),
line: trace[0].getLineNumber(),
});
}
let dataToSend = {
api_key: sdkToken,
project_id: apiKey,
version: PAYLOAD_VERSION,
sdk: sdk,
data: {
server: {
timezone: CACHED_TIMEZONE,
os: CACHED_OS_INFO,
software: null,
signature: null,
protocol: protocol,
},
language: {
name: "node",
version: CACHED_NODE_VERSION,
},
request: {
timestamp: getCachedTimestamp(),
ip: req.ip,
url: getRequestUrl(req),
user_agent: req.get("user-agent"),
method: req.method,
headers: maskSensitiveValues(req.headers, fieldsToMaskMap),
body: maskedRequestPayload !== undefined ? maskedRequestPayload : null,
route_path: getRoutePath(req),
},
response: {
headers: maskSensitiveValues(responseHeaders, fieldsToMaskMap),
code: res.statusCode,
size: res._contentLength,
load_time: getRequestDuration(requestStartTime),
body: maskedResponseBody !== undefined ? maskedResponseBody : null,
},
errors: errors,
},
};
return dataToSend;
};
/**
* Prepares the payload which is sent to Treblle.
*
* @param {object} Hono context object
* @param {object} settings
* @param {string} settings.sdkToken Treblle SDK token
* @param {string} settings.apiKey Treblle API key
* @param {number[]} settings.requestStartTime when the request started
* @param {object} settings.fieldsToMaskMap map of fields to mask
*/
const generateHonoTrebllePayload = function (
honoContext,
{ sdkToken, apiKey, requestStartTime, error, fieldsToMaskMap }
) {
const payload =
honoContext.req.method === "GET"
? honoContext.req.queries()
: honoContext.__treblle_body_request
const parsedPayload = getPayload(payload);
const sizeCheckedPayload = checkPayloadSize(parsedPayload);
const maskedRequestPayload = maskSensitiveValues(
sizeCheckedPayload,
fieldsToMaskMap
);
let responseBodySize = honoContext.__treblle_body_response_size || null;
let errors = [];
let maskedResponseBody;
let parsedResponseBody;
try {
let originalResponseBody = honoContext.__treblle_body_response;
// if the response is streamed it could be a buffer
// so we'll convert it to a string first
if (Buffer.isBuffer(originalResponseBody)) {
originalResponseBody = originalResponseBody.toString("utf8");
}
if (typeof originalResponseBody === "string") {
parsedResponseBody = JSON.parse(originalResponseBody);
const sizeCheckedResponseBody = checkPayloadSize(parsedResponseBody);
maskedResponseBody = maskSensitiveValues(
sizeCheckedResponseBody,
fieldsToMaskMap
);
} else if (typeof originalResponseBody === "object") {
const sizeCheckedResponseBody = checkPayloadSize(originalResponseBody);
maskedResponseBody = maskSensitiveValues(
sizeCheckedResponseBody,
fieldsToMaskMap
);
}
} catch {
// if we can't parse the body we'll leave it empty and set an error
responseBodySize = null;
errors.push({
source: "onShutdown",
type: "INVALID_JSON",
message: "Invalid JSON format",
file: null,
line: null,
});
}
// Hono uses HTTP/1.1 by default, but we'll try to detect version
const protocol = "HTTP/1.1";
if (error) {
const trace = stackTrace.parse(error);
errors.push({
source: "onException",
type: "UNHANDLED_EXCEPTION",
message: error.message,
file: trace[0].getFileName(),
line: trace[0].getLineNumber(),
});
}
let dataToSend = {
api_key: sdkToken,
project_id: apiKey,
version: PAYLOAD_VERSION,
sdk: "hono",
data: {
server: {
timezone: CACHED_TIMEZONE,
os: CACHED_OS_INFO,
software: null,
signature: null,
protocol: protocol,
},
language: {
name: "node",
version: CACHED_NODE_VERSION,
},
request: {
timestamp: getCachedTimestamp(),
ip: getHonoClientIP(honoContext),
url: getHonoRequestUrl(honoContext),
user_agent: honoContext.req.header("user-agent"),
method: honoContext.req.method,
headers: maskSensitiveValues(
getHonoHeaders(honoContext.req),
fieldsToMaskMap
),
body: maskedRequestPayload !== undefined ? maskedRequestPayload : null,
route_path: getHonoRoutePath(honoContext),
},
response: {
headers: maskSensitiveValues(
getHonoResponseHeaders(honoContext.res),
fieldsToMaskMap
),
code: honoContext.res.status,
size: responseBodySize,
load_time: getRequestDuration(requestStartTime),
body: maskedResponseBody !== undefined ? maskedResponseBody : null,
},
errors: errors,
},
};
return dataToSend;
};
/**
* Prepares the payload which is sent to Treblle.
*
* @param {object} Koa context object
* @param {object} settings
* @param {string} settings.sdkToken Treblle SDK token
* @param {string} settings.apiKey Treblle API key
* @param {number[]} settings.requestStartTime when the request started
* @param {object} settings.fieldsToMaskMap map of fields to mask
*/
const generateKoaTrebllePayload = function (
koaContext,
{ sdkToken, apiKey, requestStartTime, error, fieldsToMaskMap, sdk = "koa" }
) {
const payload =
koaContext.request.method === "GET"
? koaContext.request.query
: koaContext.request.body;
const parsedPayload = getPayload(payload);
const sizeCheckedPayload = checkPayloadSize(parsedPayload);
const maskedRequestPayload = maskSensitiveValues(
sizeCheckedPayload,
fieldsToMaskMap
);
const responseHeaders = koaContext.response.headers;
let errors = [];
// We should be able to parse this, but you never know if users will try doing something weird...
// TODO - figure out Koa buffers & streaming
let maskedResponseBody;
let parsedResponseBody;
try {
let originalResponseBody = koaContext.response.body;
// if the response is streamed it could be a buffer
// so we'll convert it to a string first
if (Buffer.isBuffer(originalResponseBody)) {
originalResponseBody = originalResponseBody.toString("utf8");
}
if (typeof originalResponseBody === "string") {
parsedResponseBody = JSON.parse(originalResponseBody);
const sizeCheckedResponseBody = checkPayloadSize(parsedResponseBody);
maskedResponseBody = maskSensitiveValues(
sizeCheckedResponseBody,
fieldsToMaskMap
);
} else if (typeof originalResponseBody === "object") {
const sizeCheckedResponseBody = checkPayloadSize(originalResponseBody);
maskedResponseBody = maskSensitiveValues(
sizeCheckedResponseBody,
fieldsToMaskMap
);
}
} catch {
// if we can't parse the body we'll leave it empty and set an error
errors.push({
source: "onShutdown",
type: "INVALID_JSON",
message: "Invalid JSON format",
file: null,
line: null,
});
}
const protocol = `${koaContext.request.protocol.toUpperCase()}/${
koaContext.request.req.httpVersion
}`;
if (error) {
const trace = stackTrace.parse(error);
errors.push({
source: "onException",
type: "UNHANDLED_EXCEPTION",
message: error.message,
file: trace[0].getFileName(),
line: trace[0].getLineNumber(),
});
}
let dataToSend = {
api_key: sdkToken,
project_id: apiKey,
version: PAYLOAD_VERSION,
sdk: sdk,
data: {
server: {
timezone: CACHED_TIMEZONE,
os: CACHED_OS_INFO,
software: null,
signature: null,
protocol: protocol,
},
language: {
name: "node",
version: CACHED_NODE_VERSION,
},
request: {
timestamp: getCachedTimestamp(),
ip: koaContext.request.ip,
url: getRequestUrl(koaContext.request),
user_agent: koaContext.request.header["user-agent"],
method: koaContext.request.method,
headers: maskSensitiveValues(
koaContext.request.headers,
fieldsToMaskMap
),
body: maskedRequestPayload !== undefined ? maskedRequestPayload : null,
route_path: getKoaRoutePath(koaContext),
},
response: {
headers: maskSensitiveValues(responseHeaders, fieldsToMaskMap),
code: koaContext.response.status,
size: koaContext.response.length || null,
load_time: getRequestDuration(requestStartTime),
body: maskedResponseBody !== undefined ? maskedResponseBody : null,
},
errors: errors,
},
};
return dataToSend;
};
function sendExpressPayloadToTreblle(
req,
res,
{ sdkToken, apiKey, requestStartTime, error, fieldsToMaskMap, debug, sdk = "express" }
) {
let trebllePayload = generateTrebllePayload(req, res, {
sdkToken,
apiKey,
requestStartTime,
error,
fieldsToMaskMap,
sdk,
});
sendPayloadToTreblleApi({ apiKey: sdkToken, trebllePayload, debug });
}
function sendKoaPayloadToTreblle(
koaContext,
{ sdkToken, apiKey, requestStartTime, fieldsToMaskMap, debug, error, sdk = "koa" }
) {
let trebllePayload = generateKoaTrebllePayload(koaContext, {
sdkToken,
apiKey,
requestStartTime,
error,
fieldsToMaskMap,
sdk,
});
sendPayloadToTreblleApi({ apiKey: sdkToken, trebllePayload, debug });
}
async function sendHonoPayloadToTreblle(
honoContext,
{ sdkToken, apiKey, requestStartTime, fieldsToMaskMap, debug, error }
) {
let trebllePayload = generateHonoTrebllePayload(honoContext, {
sdkToken,
apiKey,
requestStartTime,
error,
fieldsToMaskMap,
});
return sendPayloadToTreblleApiAsync({
apiKey: sdkToken,
trebllePayload,
debug,
});
}
function sendPayloadToTreblleApi({ apiKey, trebllePayload, debug }) {
sendPayloadToTreblleApiAsync({ apiKey, trebllePayload, debug }).then(
() => {},
() => {}
);
}
async function sendPayloadToTreblleApiAsync({ apiKey, trebllePayload, debug }) {
let f;
// Check for global fetch first (Cloudflare Workers, modern Node.js)
if (typeof fetch === "function") {
f = fetch;
}
// Check for node-fetch (Node.js environments)
else if (nodeFetch && typeof nodeFetch === "function") {
f = nodeFetch;
} else if (nodeFetch && typeof nodeFetch.default === "function") {
f = nodeFetch.default;
} else {
if (debug) {
console.warn("Treblle error: No fetch implementation available. Install node-fetch for Node.js environments.");
}
return;
}
// Add timeout and agent configuration
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const endpoint = getRandomEndpoint();
await f(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"Accept-Encoding": "gzip, deflate",
Connection: "keep-alive",
"User-Agent": `treblle-node/${VERSION}`,
},
body: JSON.stringify(trebllePayload),
agent: (url) => (url.protocol === "https:" ? HTTPS_AGENT : HTTP_AGENT),
timeout: 5000,
signal: controller.signal,
}).then(
(response) => {
clearTimeout(timeoutId);
if (debug && response.ok === false) {
logTreblleResponseError(response);
}
},
(error) => {
clearTimeout(timeoutId);
if (debug) {
logRequestFailed(error);
}
}
);
}
async function logTreblleResponseError(response) {
try {
const responseBody = await response.json();
logError(response, responseBody);
return;
} catch (_error) {
// ignore _error here, it means the response wasn't JSON
}
try {
const responseBody = await response.text();
logError(response, responseBody);
return;
} catch (_error) {
// ignore _error here, it means the response wasn't text
}
logError(response);
}
function logError(response, responseBody) {
console.log(
`[error] Sending data to Treblle failed - status: ${response.statusText} (${response.status})`,
responseBody
);
}
function logRequestFailed(error) {
console.error(
"[error] Sending data to Treblle failed (it's possibly a network error)",
error
);
}
/**
* Creates a platform-aware start time for duration measurement
* @returns {number[]|number} hrtime array for Node.js or timestamp for other platforms
*/
function createStartTime() {
/// Use performance.now() if available
if (typeof performance !== 'undefined' && performance.now) {
return performance.now();
}
// Fallback to process.hrtime() if we're in Node.js environment and its available
else if (typeof process !== 'undefined' && process.hrtime && typeof process.hrtime === 'function') {
return process.hrtime();
}
// Last fallback to Date.now()
else {
return Date.now();
}
}
/**
* Calculates the request duration in milliseconds.
*
* @param {number[]|number} startTime
* @returns {number} Duration in milliseconds
*/
function getRequestDuration(startTime) {
// Handle Node.js hrtime format
if (Array.isArray(startTime) && startTime.length === 2) {
if (typeof process !== 'undefined' && process.hrtime && typeof process.hrtime === 'function') {
const NS_PER_SEC = 1e9;
const NS_TO_MS = 1e6;
const diff = process.hrtime(startTime);
const milliseconds = (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS;
return Math.ceil(milliseconds);
}
// If we have hrtime format but no process.hrtime, return 0
return 0;
}
// Handle performance.now() or Date.now() format (already in milliseconds)
if (typeof startTime === 'number') {
let endTime;
if (typeof performance !== 'undefined' && performance.now) {
endTime = performance.now();
} else {
endTime = Date.now();
}
// Return milliseconds
return Math.ceil(endTime - startTime);
}
// Fallback: if we can't determine timing, return 0
return 0;
}
function getRequestUrl(req) {
const fullUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
return fullUrl;
}
function getPayload(payload) {
if (typeof payload === "object") return payload;
if (typeof payload === "string") {
try {
return JSON.parse(payload);
} catch (error) {
// if we can't parse it we'll just return null
return null;
}
}
}
/**
* Extracts the route path pattern from Express/NestJS request
* @param {object} req Express request object
* @returns {string|null} Route pattern or null if not available
*/
function getRoutePath(req) {
let routePath = null;
// Express/NestJS route pattern
if (req.route && req.route.path) {
routePath = req.route.path;
}
// Fallback: try to get from baseUrl + route
else if (req.baseUrl && req.route && req.route.path) {
routePath = req.baseUrl + req.route.path;
}
// Transform Express :param syntax to OpenAPI {param} format
if (routePath) {
return transformToOpenAPIFormat(routePath);
}
return null;
}
/**
* Extracts the route path pattern from Koa context
* @param {object} ctx Koa context object
* @returns {string|null} Route pattern or null if not available
*/
function getKoaRoutePath(ctx) {
let routePath = null;
// Koa Router path pattern
if (ctx._matchedRoute) {
routePath = ctx._matchedRoute;
}
// Alternative: check for matched routes array
else if (ctx.matched && ctx.matched.length > 0) {
const lastMatch = ctx.matched[ctx.matched.length - 1];
if (lastMatch && lastMatch.path) {
routePath = lastMatch.path;
}
}
// Check for router layer path
else if (ctx.routerPath) {
routePath = ctx.routerPath;
}
// Transform Koa :param syntax to OpenAPI {param} format
if (routePath) {
return transformToOpenAPIFormat(routePath);
}
return null;
}
/**
* Extracts the route path pattern from Hono context
* @param {object} c Hono context object
* @returns {string|null} Route pattern or null if not available
*/
function getHonoRoutePath(c) {
let routePattern = null;
try {
// Fallback: Check deprecated routePath property in request (pre v4.8.0)
if (c.req && c.req.routePath) {
routePattern = c.req.routePath;
}
// Fallback: Check if context has routePath method
else if (c.routePath && typeof c.routePath === "function") {
routePattern = c.routePath();
}
// Fallback: Check if context has routePath as property
else if (c.routePath && typeof c.routePath === "string") {
routePattern = c.routePath;
}
} catch (error) {
// If route helpers fail, continue without route pattern
routePattern = null;
}
// Transform Hono :param syntax to OpenAPI {param} format
if (routePattern) {
return transformToOpenAPIFormat(routePattern);
}
return null;
}
/**
* Gets client IP from Hono context
* @param {object} c Hono context object
* @returns {string|null} Client IP address
*/
function getHonoClientIP(c) {
// Try to get IP from various Hono context sources
return (
c.req.header("x-forwarded-for")?.split(",")[0].trim() ||
c.req.header("x-real-ip") ||
c.req.header("cf-connecting-ip") ||
c.env?.REMOTE_ADDR ||
null
);
}
/**
* Gets request URL from Hono context
* @param {object} c Hono context object
* @returns {string} Request URL
*/
function getHonoRequestUrl(c) {
return c.req.url;
}
/**
* Gets headers from Hono request
* @param {object} req Hono request object
* @returns {object} Headers object
*/
function getHonoHeaders(req) {
const headers = {};
// Convert Headers object to plain object
if (req.raw && req.raw.headers) {
for (const [key, value] of req.raw.headers.entries()) {
headers[key.toLowerCase()] = value;
}
}
return headers;
}
/**
* Gets headers from Hono response
* @param {object} res Hono response object
* @returns {object} Headers object
*/
function getHonoResponseHeaders(res) {
const headers = {};
// Convert Headers object to plain object
if (res.headers) {
for (const [key, value] of res.headers.entries()) {
headers[key.toLowerCase()] = value;
}
}
return headers;
}
/**
* Transforms route paths from :param syntax to OpenAPI {param} format
* @param {string} routePath Route path with :param syntax
* @returns {string} Route path with {param} syntax
*/
function transformToOpenAPIFormat(routePath) {
// Transform :param to {param} and :param? to {param} (optional params)
return routePath.replace(/:([a-zA-Z_$][a-zA-Z0-9_$]*)\??/g, "{$1}");
}
module.exports = {
sendExpressPayloadToTreblle,
sendKoaPayloadToTreblle,
sendHonoPayloadToTreblle,
sendPayloadToTreblleApi,
getPayloadSize,
checkPayloadSize,
getRandomEndpoint,
createStartTime,
};