Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.4",
"version": "1.2.5",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
14 changes: 6 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import PlansFactory from './models/plansFactory';
import BusinessOperationsFactory from './models/businessOperationsFactory';
import schema from './schema';
import { graphqlUploadExpress } from 'graphql-upload';
import morgan from 'morgan';
import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics';
import { requestLogger } from './utils/logger';

/**
* Option to enable playground
Expand Down Expand Up @@ -85,19 +85,17 @@ class HawkAPI {
next();
});

/**
* Setup request logger.
* Uses 'combined' format in production for Apache-style logging,
* and 'dev' format in development for colored, concise output.
*/
this.app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));

/**
* Add metrics middleware to track HTTP requests
*/
this.app.use(metricsMiddleware);

this.app.use(express.json());

/**
* Setup request logger with custom formatters (GraphQL operation name support)
*/
this.app.use(requestLogger);
this.app.use(bodyParser.urlencoded({ extended: false }));
this.app.use('/static', express.static(`./static`));

Expand Down
148 changes: 148 additions & 0 deletions src/metrics/mongodb.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import promClient from 'prom-client';
import { MongoClient, MongoClientOptions } from 'mongodb';
import { Effect, sgr } from '../utils/ansi';

/**
* MongoDB command duration histogram
Expand Down Expand Up @@ -113,12 +114,155 @@ export function withMongoMetrics(options: MongoClientOptions = {}): MongoClientO
};
}

/**
* Format filter/update parameters for logging
* @param params - Parameters to format
* @returns Formatted string
*/
function formatParams(params: any): string {
if (!params || Object.keys(params).length === 0) {
return '';
}

try {
return JSON.stringify(params);
} catch (e) {
return String(params);
}
}

/**
* Colorize duration based on performance thresholds
* @param duration - Duration in milliseconds
* @returns Colorized duration string
*/
function colorizeDuration(duration: number): string {
let color: Effect;

if (duration < 50) {
color = Effect.ForegroundGreen;
} else if (duration < 100) {
color = Effect.ForegroundYellow;
} else {
color = Effect.ForegroundRed;
}

return sgr(`${duration}ms`, color);
}

/**
* Interface for storing command information with timestamp
*/
interface StoredCommandInfo {
formattedCommand: string;
timestamp: number;
}

/**
* Map to store formatted command information by requestId
*/
const commandInfoMap = new Map<number, StoredCommandInfo>();

/**
* Timeout for cleaning up stale command info (30 seconds)
*/
const COMMAND_INFO_TIMEOUT_MS = 30000;

/**
* Cleanup stale command info to prevent memory leaks
* Removes entries older than COMMAND_INFO_TIMEOUT_MS
*/
function cleanupStaleCommandInfo(): void {
const now = Date.now();
const keysToDelete: number[] = [];

for (const [requestId, info] of commandInfoMap.entries()) {
if (now - info.timestamp > COMMAND_INFO_TIMEOUT_MS) {
keysToDelete.push(requestId);
}
}

if (keysToDelete.length > 0) {
console.warn(`Cleaning up ${keysToDelete.length} stale MongoDB command info entries (possible memory leak)`);
for (const key of keysToDelete) {
commandInfoMap.delete(key);
}
}
}

/**
* Periodic cleanup interval
*/
setInterval(cleanupStaleCommandInfo, COMMAND_INFO_TIMEOUT_MS);

/**
* Store MongoDB command details for later logging
* @param event - MongoDB command event
*/
function storeCommandInfo(event: any): void {
const collectionRaw = extractCollectionFromCommand(event.command, event.commandName);
const collection = sgr(normalizeCollectionName(collectionRaw), Effect.ForegroundGreen);
const db = event.databaseName || 'unknown db';
const commandName = sgr(event.commandName, Effect.ForegroundRed);
const filter = event.command.filter;
const update = event.command.update;
const pipeline = event.command.pipeline;
const projection = event.command.projection;
const params = filter || update || pipeline;
const paramsStr = formatParams(params);
const projectionStr = projection ? ` projection: ${formatParams(projection)}` : '';

const formattedCommand = `[${event.requestId}] ${db}.${collection}.${commandName}(${paramsStr})${projectionStr}`;

commandInfoMap.set(event.requestId, {
formattedCommand,
timestamp: Date.now(),
});
}

/**
* Log MongoDB command success to console
* Format: [requestId] db.collection.command(params) ✓ duration
* @param event - MongoDB command event
*/
function logCommandSucceeded(event: any): void {
const info = commandInfoMap.get(event.requestId);
const durationStr = colorizeDuration(event.duration);

if (info) {
console.log(`${info.formattedCommand} ✓ ${durationStr}`);
commandInfoMap.delete(event.requestId);
} else {
console.log(`[${event.requestId}] ${event.commandName} ✓ ${durationStr}`);
}
}

/**
* Log MongoDB command failure to console
* Format: [requestId] db.collection.command(params) ✗ error duration
* @param event - MongoDB command event
*/
function logCommandFailed(event: any): void {
const errorMsg = event.failure?.message || event.failure?.errmsg || 'Unknown error';
const info = commandInfoMap.get(event.requestId);
const durationStr = colorizeDuration(event.duration);

if (info) {
console.error(`${info.formattedCommand} ✗ ${errorMsg} ${durationStr}`);
commandInfoMap.delete(event.requestId);
} else {
console.error(`[${event.requestId}] ${event.commandName} ✗ ${errorMsg} ${durationStr}`);
}
}

/**
* Setup MongoDB metrics monitoring on a MongoClient
* @param client - MongoDB client to monitor
*/
export function setupMongoMetrics(client: MongoClient): void {
client.on('commandStarted', (event) => {
storeCommandInfo(event);

// Store start time and metadata for this command
const metadataKey = `${event.requestId}`;

Expand All @@ -139,6 +283,8 @@ export function setupMongoMetrics(client: MongoClient): void {
});

client.on('commandSucceeded', (event) => {
logCommandSucceeded(event);

const metadataKey = `${event.requestId}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata = (client as any)[metadataKey];
Expand All @@ -157,6 +303,8 @@ export function setupMongoMetrics(client: MongoClient): void {
});

client.on('commandFailed', (event) => {
logCommandFailed(event);

const metadataKey = `${event.requestId}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata = (client as any)[metadataKey];
Expand Down
2 changes: 1 addition & 1 deletion src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ class EventsFactory extends Factory {
async getEventRelease(eventId) {
const eventOriginal = await this.findById(eventId);

if (!eventOriginal) {
if (!eventOriginal || !eventOriginal.payload.release) {
return null;
}

Expand Down
4 changes: 3 additions & 1 deletion src/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ export async function setupConnections(): Promise<void> {
databases.hawk = hawkMongoClient.db();
databases.events = eventsMongoClient.db();

// Setup metrics monitoring for both clients
/**
* Log and and measure MongoDB metrics
*/
setupMongoMetrics(hawkMongoClient);
setupMongoMetrics(eventsMongoClient);
} catch (e) {
Expand Down
44 changes: 44 additions & 0 deletions src/utils/ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* ANSI escape codes for text formatting
*/
export enum Effect {
Reset = '\x1b[0m',
Bold = '\x1b[1m',
Underline = '\x1b[4m',
CrossedOut = '\x1b[9m',
BoldOff = '\x1b[22m',
UnderlineOff = '\x1b[24m',
CrossedOutOff = '\x1b[29m',
ForegroundRed = '\x1b[31m',
ForegroundGreen = '\x1b[32m',
ForegroundYellow = '\x1b[33m',
ForegroundBlue = '\x1b[34m',
ForegroundMagenta = '\x1b[35m',
ForegroundCyan = '\x1b[36m',
ForegroundWhite = '\x1b[37m',
ForegroundGray = '\x1b[90m',
BackgroundRed = '\x1b[41m',
BackgroundGreen = '\x1b[42m',
BackgroundYellow = '\x1b[43m',
BackgroundBlue = '\x1b[44m',
BackgroundMagenta = '\x1b[45m',
BackgroundCyan = '\x1b[46m',
BackgroundWhite = '\x1b[47m',
BackgroundGray = '\x1b[100m',
};

/**
* ANSI escape code for setting visual effects using SGR (Select Graphic Rendition) subset
*
* @example console.log('Hello, ${sgr('world', Effect.ForegroundRed)}');
*
*
* @param message - The message to colorize
* @param color - The color to apply
* @returns The colored message
*/
export function sgr(message: string, color: Effect | Effect[]): string {
const colorCode = Array.isArray(color) ? color.join('') : color ?? Effect.Reset;

return `${colorCode}${message}${Effect.Reset}`;
}
41 changes: 41 additions & 0 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import morgan from 'morgan';
import express from 'express';
import { sgr, Effect } from './ansi';

/**
* Setup custom GraphQL-aware morgan token.
* Extracts operation name from GraphQL requests to show query/mutation names in logs.
*/
morgan.token('graphql-operation', (req: express.Request) => {
if (req.body && req.body.operationName) {
return req.body.operationName;
}
if (req.body && req.body.query) {
/* Try to extract operation name from query string if operationName is not provided */
const match = req.body.query.match(/(?:query|mutation)\s+(\w+)/);
const isMutation = req.body.query.includes('mutation');

const effect = isMutation ? Effect.ForegroundRed : Effect.ForegroundMagenta;
const prefix = sgr(isMutation ? 'Mutation' : 'Query', effect);

if (match && match[1]) {
return prefix + ' ' + sgr(sgr(match[1], effect), Effect.Bold);
}
}

return '-';
});

/**
* Custom morgan format for GraphQL-aware logging.
* Development: shows method, url, operation name, status, response time, content length
* Production: Apache-style format with operation name included
*/
const customFormat = process.env.NODE_ENV === 'production'
? ':remote-addr - :remote-user [:date[clf]] ":method :url :graphql-operation" :status :res[content-length] bytes - :response-time ms'
: ':method :url :graphql-operation :status :res[content-length] bytes - :response-time ms';

/**
* Configured morgan middleware with GraphQL operation name logging
*/
export const requestLogger = morgan(customFormat);
36 changes: 0 additions & 36 deletions src/utils/utils.js

This file was deleted.

Loading