import { MMApiClient } from '../../services/mm-api-service/mm-api-service.js'; import logger from '../../services/reporting-service/logger.js'; import type { LogDeduper } from './log-deduper.js'; export type LogLevelString = 'info' | 'warn' | 'error'; /** * dedupeKey - key used to deduplicate log entries when a LogDeduper is injected into the MMConnectorLogger * * A recommended practice is to use a meaningful unique identifier for this log entry type. * Examples: SyncFromERPCompleted * SyncFromERPError:HTTPError */ export class LogEntry { level: LogLevelString; message: string; dedupeKey: string; eventTime: number; constructor(params: { level: LogLevelString; message: string; dedupeKey: string}) { this.level = params.level; this.message = params.message; this.dedupeKey = params.dedupeKey; this.eventTime = Date.now(); } } /** * The response from the MM connector logging API */ export interface LogResponse { message: string; processed?: number; } /** * Helper function to check if a value is a LogResponse */ function isLogResponse(value: unknown): value is LogResponse { if (value === null || typeof value !== 'object') return false; const v = value as Record; if (typeof v.message !== 'string') return false; if ('processed' in v && typeof v.processed !== 'number') return false; return true; } /** * MM Connector Logger for sending progress and error updates to the MM cloud * * This class provides helper methods to send individual logs to the * MM connector logging API, providing support for validation, retries, and error handling * according to the API specification. * * By injecting a LogDeduper, the log entries are deduplicated across jobs over a specified time window */ export class MMConnectorLogger { private readonly MAX_MSG_LEN = 2000; private mmApiClient: MMApiClient; private readonly deduper?: LogDeduper; private readonly source: string; constructor(source: string, deduper?: LogDeduper) { if (source.length < 1 || source.length > 64) { throw new Error('source must be 1-64 characters'); } this.mmApiClient = new MMApiClient(); this.deduper = deduper; this.source = source; } // Deduplication helpers are delegated to injected FileLogDeduper /** * Send a single log entry to the MM cloud with deduplication. * * The deduplication is handled by the injected LogDeduper. * If no deduper is injected, the log entry is sent without deduplication. * * The standard deduper, FileLogDeduper, stores the deduplication state in a file, * allowing deduplication across jobs, * * @param logEntry - The log entry to send * @returns Promise resolving to the API response or null if suppressed * @throws HTTPError if the request fails or Error if the log entry is invalid */ async sendLog(logEntry: LogEntry): Promise { this.validateLogEntry(logEntry); const now = Date.now(); let messageToSend = logEntry.message; if (this.deduper) { const decision = await this.deduper.decide(logEntry, now); if (decision === null) return null; // suppressed messageToSend = decision; } try { const logEntryToSend = { source: this.source, level: logEntry.level, message: messageToSend }; const response = await this.mmApiClient.sendConnectorLog(logEntryToSend); if (this.deduper) { await this.deduper.onSuccess(logEntry, now); } if (!isLogResponse(response)) { logger.warn('Unexpected success response format from MM API for connector log', { response }); return { message: 'Unexpected success response format when sending log' }; } return { message: response.message }; } catch (error) { logger.error('Failed to send log to MM cloud', { level: logEntry.level, error: error instanceof Error ? error.message : 'Unknown error' }); throw error; } } /** * @throws Error if validation fails */ private validateLogEntry(logEntry: LogEntry): void { const allowedLevels: ReadonlyArray = ['info', 'warn', 'error']; if (!logEntry.level || !allowedLevels.includes(logEntry.level)) { throw new Error(`level must be one of: ${allowedLevels.join(', ')}`); } if (!logEntry.message || typeof logEntry.message !== 'string') { throw new Error('message is required and must be a string'); } // Truncate message to MAX_MSG_LEN characters logEntry.message = logEntry.message.slice(0, this.MAX_MSG_LEN); if (!logEntry.dedupeKey || typeof logEntry.dedupeKey !== 'string') { throw new Error('dedupeKey is required and must be a string'); } if (logEntry.dedupeKey.trim().length < 1) { throw new Error('dedupeKey must be a non-empty string'); } } /** * Retry all failed transmissions silently * This method attempts to retry all messages that failed to transmit * and removes them from the failed list if successful, else leaves them for the client to retry * * Expected usage is by a client to call this as part of its own retry mechanism */ async retryFailedTransmissions(): Promise { if (!this.deduper || !this.deduper.retryFailedTransmissions) { return; } await this.deduper.retryFailedTransmissions(async (entry, message) => { await this.mmApiClient.sendConnectorLog({ source: this.source, level: entry.level, message }); }); } /** * Clean up resources */ async destroy(): Promise { await this.mmApiClient.destroy(); } }