syntropylog
Version:
An instance manager with observability for Node.js applications
289 lines (283 loc) • 13.6 kB
JavaScript
'use strict';
/**
* @class InstrumentedBrokerClient
* @description Wraps a user-provided broker adapter to automatically handle
* logging, context propagation, and distributed tracing.
*/
class InstrumentedBrokerClient {
/**
* @constructor
* @param {IBrokerAdapter} adapter - The concrete broker adapter implementation (e.g., for RabbitMQ, Kafka).
* @param {ILogger} logger - The logger instance for this client.
* @param {IContextManager} contextManager - The manager for handling asynchronous contexts.
* @param {BrokerInstanceConfig} config - The configuration for this specific instance.
*/
constructor(adapter, logger, contextManager, config) {
this.adapter = adapter;
this.logger = logger;
this.contextManager = contextManager;
this.config = config;
this.instanceName = config.instanceName;
}
/**
* Establishes a connection to the broker, wrapping the adapter's connect
* method with logging.
* @returns {Promise<void>}
*/
async connect() {
this.logger.info('Connecting to broker...');
await this.adapter.connect();
this.logger.info('Successfully connected to broker.');
}
/**
* Disconnects from the broker, wrapping the adapter's disconnect method
* with logging.
* @returns {Promise<void>}
*/
async disconnect() {
this.logger.info('Disconnecting from broker...');
await this.adapter.disconnect();
this.logger.info('Successfully disconnected from broker.');
}
/**
* Publishes a message, automatically injecting the current `correlation-id`
* from the active context into the message headers.
* @param {string} topic - The destination topic or routing key for the message.
* @param {BrokerMessage} message - The message to be published. The `correlation-id`
* will be added to its headers if not present.
* @returns {Promise<void>}
*/
async publish(topic, message) {
if (!message.headers) {
message.headers = {};
}
// Get current correlation ID from the active context (only if it exists, don't generate new)
const currentCorrelationId = (this.contextManager.get(this.contextManager.getCorrelationIdHeaderName()) || this.contextManager.get('correlationId'));
// 1. Inject context into headers based on the configuration.
if (this.config.propagate?.includes('*')) {
// Wildcard behavior: Propagate the entire context map.
const contextObject = this.contextManager.getAll();
for (const key in contextObject) {
if (Object.prototype.hasOwnProperty.call(contextObject, key)) {
const value = contextObject[key];
if (typeof value === 'string' || Buffer.isBuffer(value)) {
message.headers[key] = value;
}
}
}
}
else if (this.config.propagate && Array.isArray(this.config.propagate)) {
// New behavior: Propagate only specified context keys.
for (const key of this.config.propagate) {
const value = this.contextManager.get(key);
if (typeof value === 'string' || Buffer.isBuffer(value)) {
message.headers[key] = value;
}
}
}
else if (this.config.propagateFullContext) {
// DEPRECATED: Propagate the entire context map.
const contextObject = this.contextManager.getAll();
for (const key in contextObject) {
if (Object.prototype.hasOwnProperty.call(contextObject, key)) {
const value = contextObject[key];
if (typeof value === 'string' || Buffer.isBuffer(value)) {
// Note: Broker headers typically support string | Buffer.
message.headers[key] = value;
}
}
}
}
// Only propagate correlation ID if it exists in the context (don't generate new)
if (currentCorrelationId) {
message.headers[this.contextManager.getCorrelationIdHeaderName()] =
currentCorrelationId;
}
const transactionId = this.contextManager.getTransactionId();
if (transactionId) {
message.headers[this.contextManager.getTransactionIdHeaderName()] =
transactionId;
}
this.logger.info({
topic,
messageId: message.headers?.['id'] instanceof Buffer
? message.headers?.['id'].toString()
: message.headers?.['id'],
correlationId: currentCorrelationId, // Log the correlation ID being used
}, 'Publishing message...');
await this.adapter.publish(topic, message);
this.logger.info({
topic,
messageId: message.headers?.['id'] instanceof Buffer
? message.headers?.['id'].toString()
: message.headers?.['id'],
correlationId: currentCorrelationId, // Log the correlation ID being used
}, 'Message published successfully.');
}
/**
* Subscribes to a topic. It wraps the user's message handler to automatically
* create a new asynchronous context for each incoming message. If a `correlation-id`
* is found in the message headers, it is used to initialize the new context.
* @param {string} topic - The topic or queue to subscribe to.
* @param {MessageHandler} handler - The user-provided function to process incoming messages.
* @returns {Promise<void>}
*/
async subscribe(topic, handler) {
this.logger.info({ topic }, 'Subscribing to topic...');
// Wrap the user's handler to implement automatic context propagation.
const instrumentedHandler = async (message, controls) => {
// Get correlation ID from message headers first
const messageCorrelationId = message.headers?.[this.contextManager.getCorrelationIdHeaderName()];
// Get current correlation ID from context (but don't generate new one if not exists)
const currentCorrelationId = this.contextManager.get(this.contextManager.getCorrelationIdHeaderName());
// If message has different correlation ID, restore context from message
if (messageCorrelationId &&
messageCorrelationId !== currentCorrelationId) {
await this.contextManager.run(async () => {
if (message.headers) {
for (const key in message.headers) {
this.contextManager.set(key, message.headers[key]);
}
}
// Use the message correlation ID for logging instead of generating a new one
const correlationId = messageCorrelationId;
this.logger.info({ topic, correlationId }, 'Received message.');
// Also wrap the lifecycle controls to add logging for ack/nack actions.
const instrumentedControls = {
ack: async () => {
await controls.ack();
this.logger.debug({ topic, correlationId }, 'Message acknowledged (ack).');
},
nack: async (requeue) => {
await controls.nack(requeue);
this.logger.warn({ topic, correlationId, requeue }, 'Message negatively acknowledged (nack).');
},
};
// Execute the original user-provided handler.
await handler(message, instrumentedControls);
});
}
else {
// Use current context, just set message headers if needed
if (message.headers) {
for (const key in message.headers) {
this.contextManager.set(key, message.headers[key]);
}
}
// Use the message correlation ID if available, otherwise use current context (but don't generate new one)
const correlationId = messageCorrelationId ||
this.contextManager.get(this.contextManager.getCorrelationIdHeaderName());
this.logger.info({ topic, correlationId }, 'Received message.');
// Also wrap the lifecycle controls to add logging for ack/nack actions.
const instrumentedControls = {
ack: async () => {
await controls.ack();
this.logger.debug({ topic, correlationId }, 'Message acknowledged (ack).');
},
nack: async (requeue) => {
await controls.nack(requeue);
this.logger.warn({ topic, correlationId, requeue }, 'Message negatively acknowledged (nack).');
},
};
// Execute the original user-provided handler.
await handler(message, instrumentedControls);
}
};
await this.adapter.subscribe(topic, instrumentedHandler);
this.logger.info({ topic }, 'Successfully subscribed to topic.');
}
}
/**
* Internal Types for SyntropyLog Framework
*
* These types and utilities are for advanced usage and internal framework operations.
* Use with caution - they may change between versions.
*/
/**
* Helper function to convert unknown error to JsonValue
* Moved from @syntropylog/types to internal types
*/
function errorToJsonValue(error) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack || null,
};
}
return String(error);
}
/**
* SyntropyLog Types - Internal types for the framework
*
* This file now uses internal types and only contains types specific to this module.
*/
// Import errorToJsonValue as value (not type)
/**
* FILE: src/brokers/BrokerManager.ts
* DESCRIPTION:
* Manages the lifecycle and creation of multiple instrumented broker client instances,
* following the same pattern as HttpManager and RedisManager.
*/
/**
* @class BrokerManager
* @description Manages the lifecycle and creation of multiple instrumented broker client instances.
* It reads the configuration, creates an `InstrumentedBrokerClient` for each defined
* instance, and provides a way to retrieve them and shut them down gracefully.
*/
class BrokerManager {
constructor(config, logger, contextManager) {
this.instances = new Map();
this.config = config;
this.logger = logger.child({ module: 'BrokerManager' });
this.contextManager = contextManager;
}
async init() {
this.logger.trace('Initializing BrokerManager...');
if (!this.config.instances || this.config.instances.length === 0) {
this.logger.debug('BrokerManager initialized, but no broker instances were defined.');
return;
}
const creationPromises = this.config.instances.map(async (instanceConfig) => {
try {
const client = new InstrumentedBrokerClient(instanceConfig.adapter, this.logger, this.contextManager, instanceConfig);
await client.connect(); // Connect is likely async
this.instances.set(instanceConfig.instanceName, client);
this.logger.info(`Broker client instance "${instanceConfig.instanceName}" created and connected successfully.`);
if (instanceConfig.instanceName === this.config.default) {
this.logger.trace(`Setting default broker instance: ${instanceConfig.instanceName}`);
this.defaultInstance = client;
}
}
catch (error) {
this.logger.error(`Failed to create broker instance "${instanceConfig.instanceName}":`, errorToJsonValue(error));
}
});
await Promise.all(creationPromises);
if (!this.defaultInstance && this.instances.size > 0) {
const firstInstance = this.instances.values().next().value;
const firstName = this.instances.keys().next().value;
this.logger.trace(`No default broker instance configured. Using first available instance: ${firstName}`);
this.defaultInstance = firstInstance;
}
}
getInstance(name) {
const instanceName = name ?? this.defaultInstance?.instanceName;
if (!instanceName) {
throw new Error('A specific instance name was not provided and no default Broker instance is configured.');
}
const instance = this.instances.get(instanceName);
if (!instance) {
throw new Error(`Broker client instance with name "${instanceName}" was not found. Check your configuration.`);
}
return instance;
}
async shutdown() {
this.logger.info('Disconnecting all broker clients...');
const shutdownPromises = Array.from(this.instances.values()).map((instance) => instance.disconnect());
await Promise.allSettled(shutdownPromises);
}
}
exports.BrokerManager = BrokerManager;
exports.InstrumentedBrokerClient = InstrumentedBrokerClient;
//# sourceMappingURL=index.cjs.map