UNPKG

@nitra/consola

Version:
319 lines (288 loc) 10 kB
// OpenTelemetry Reporter для експорту логів до OpenTelemetry Collector через HTTP export class OpenTelemetryReporter { constructor(options = {}) { this.endpoint = options.endpoint || import.meta.env.VITE_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT || 'http://localhost:4318/v1/logs' this.serviceName = options.serviceName || import.meta.env.VITE_OTEL_SERVICE_NAME || 'consola-service' this.serviceVersion = options.serviceVersion || import.meta.env.VITE_OTEL_SERVICE_VERSION || '1.0.0' this.clusterName = options.clusterName || import.meta.env.VITE_OTLP_CLUSTER_NAME || null this.namespaceName = options.namespaceName || import.meta.env.VITE_OTLP_NAMESPACE_NAME || null this.batchSize = options.batchSize || parseInt(String(import.meta.env.VITE_OTEL_BATCH_SIZE || '10'), 10) this.flushInterval = options.flushInterval || parseInt(String(import.meta.env.VITE_OTEL_FLUSH_INTERVAL || '5000'), 10) this.buffer = [] this.flushTimer = null this.headers = { 'Content-Type': 'application/json', ...options.headers } // Запускаємо таймер для періодичної відправки if (this.flushInterval > 0) { this.startFlushTimer() } } formatMessage(logObj) { const args = logObj.args || [] if (args.length === 0) return '' return args .map(arg => { if (arg instanceof Error) { let msg = `${arg.name || 'Error'}: ${arg.message || ''}` if (arg.stack && arg.stack !== arg.message) { msg += '\n' + arg.stack } return msg } if (arg === null) return 'null' if (arg === undefined) return 'undefined' if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 2) } catch { return String(arg) } } return String(arg) }) .join(' ') } getSeverityNumber(type) { // Маппінг типів consola до OpenTelemetry severity numbers const severityMap = { trace: 1, // TRACE debug: 5, // DEBUG info: 9, // INFO log: 9, // INFO warn: 13, // WARN error: 17, // ERROR fatal: 21, // FATAL success: 9, // INFO start: 9, // INFO ready: 9, // INFO fail: 17 // ERROR } return severityMap[type] || 9 } getSeverityText(type) { const textMap = { trace: 'TRACE', debug: 'DEBUG', info: 'INFO', log: 'INFO', warn: 'WARN', error: 'ERROR', fatal: 'FATAL', success: 'INFO', start: 'INFO', ready: 'INFO', fail: 'ERROR' } return textMap[type] || 'INFO' } getFileInfo() { try { const stack = new Error('Stack trace').stack if (!stack) return null const skipPatterns = ['OpenTelemetryReporter', 'otel-reporter.js', 'browser.js', 'consola', 'createLogger'] const lines = stack.split('\n').slice(4, 15) for (const line of lines) { const trimmed = line.trim() const shouldSkip = skipPatterns.some(pattern => trimmed.includes(pattern)) if (!shouldSkip) { const match = trimmed.match(/\(?([^()]+):(\d+):(\d+)\)?/) || trimmed.match(/at\s+[^(]*\(?([^:]+):(\d+):(\d+)\)?/) if (match) { let file = match[1].trim() const lineNum = match[2] if (file.includes('://')) { file = file.slice(file.indexOf('://') + 3).slice(file.indexOf('/')) } if (file.includes('/src/')) { file = file.slice(file.indexOf('/src/') + 5) } const queryIndex = file.indexOf('?') if (queryIndex > 0) file = file.slice(0, queryIndex) const hashIndex = file.indexOf('#') if (hashIndex > 0) file = file.slice(0, hashIndex) if (file.startsWith('/')) file = file.slice(1) if (file && lineNum) { return { file, line: parseInt(lineNum, 10), column: parseInt(match[3], 10) } } } } } } catch { // Ігноруємо помилки } return null } createLogRecord(logObj) { const type = logObj.type || 'log' const message = this.formatMessage(logObj) const fileInfo = this.getFileInfo() const timestamp = Date.now() * 1000000 // наносекунди const logRecord = { timeUnixNano: timestamp.toString(), severityNumber: this.getSeverityNumber(type), severityText: this.getSeverityText(type), body: { stringValue: message }, attributes: [ { key: 'log.type', value: { stringValue: type } } ] } // Додаємо інформацію про файл, якщо доступна if (fileInfo) { logRecord.attributes.push( { key: 'code.filepath', value: { stringValue: fileInfo.file } }, { key: 'code.lineno', value: { intValue: fileInfo.line } } ) if (fileInfo.column) { logRecord.attributes.push({ key: 'code.colno', value: { intValue: fileInfo.column } }) } } // Додаємо додаткові атрибути з logObj, якщо вони є if (logObj.extra && typeof logObj.extra === 'object') { for (const [key, value] of Object.entries(logObj.extra)) { logRecord.attributes.push({ key: `extra.${key}`, value: { stringValue: String(value) } }) } } return logRecord } async sendLogs(logs) { if (logs.length === 0) return const resourceAttributes = [ { key: 'service.name', value: { stringValue: this.serviceName } }, { key: 'service.version', value: { stringValue: this.serviceVersion } } ] if (this.clusterName) { resourceAttributes.push({ key: 'cluster.name', value: { stringValue: this.clusterName } }) } if (this.namespaceName) { resourceAttributes.push({ key: 'namespace.name', value: { stringValue: this.namespaceName } }) } const resourceLogs = { resource: { attributes: resourceAttributes }, scopeLogs: [ { scope: { name: 'consola', version: '1.0.0' }, logRecords: logs } ] } const payload = { resourceLogs: [resourceLogs] } try { const response = await fetch(this.endpoint, { method: 'POST', mode: 'cors', headers: this.headers, body: JSON.stringify(payload) }) if (!response.ok) { console.warn(`OpenTelemetry export failed: ${response.status} ${response.statusText}`) } } catch (error) { // Тихо ігноруємо помилки, щоб не порушити роботу додатку console.warn('OpenTelemetry export error:', error?.message || String(error)) } } async flush() { if (this.buffer.length === 0) return const logsToSend = [...this.buffer] this.buffer = [] await this.sendLogs(logsToSend) } startFlushTimer() { if (this.flushTimer) { clearInterval(this.flushTimer) } this.flushTimer = setInterval(() => { this.flush().catch(error => { console.warn('OpenTelemetry flush error:', error?.message || String(error)) }) }, this.flushInterval) } log(logObj) { try { const logRecord = this.createLogRecord(logObj) this.buffer.push(logRecord) // Відправляємо одразу, якщо буфер досяг максимального розміру if (this.buffer.length >= this.batchSize) { this.flush().catch(error => { console.warn('OpenTelemetry flush error:', error?.message || String(error)) }) } } catch (error) { // Тихо ігноруємо помилки console.warn('OpenTelemetry log processing error:', error?.message || String(error)) } } destroy() { if (this.flushTimer) { clearInterval(this.flushTimer) this.flushTimer = null } // Відправляємо залишкові логи перед знищенням return this.flush() } } /** * Створює OpenTelemetry репортер для експорту логів до OpenTelemetry Collector * * @param {Object} options - Опції репортера * @param {String} options.endpoint - URL endpoint OpenTelemetry Collector (за замовчуванням: http://localhost:4318/v1/logs) * @param {String} options.serviceName - Ім'я сервісу (за замовчуванням: consola-service) * @param {String} options.serviceVersion - Версія сервісу (за замовчуванням: 1.0.0) * @param {String} [options.clusterName] - Ім'я кластера (resource attribute, з VITE_OTLP_CLUSTER_NAME) * @param {String} [options.namespaceName] - Ім'я namespace (resource attribute, з VITE_OTLP_NAMESPACE_NAME) * @param {Number} options.batchSize - Розмір батча для відправки (за замовчуванням: 10) * @param {Number} options.flushInterval - Інтервал автоматичної відправки в мс (за замовчуванням: 5000) * @param {Object} options.headers - Додаткові HTTP заголовки * @returns {OpenTelemetryReporter} Екземпляр OpenTelemetry репортера * * @example * import { createOpenTelemetryReporter } from '@nitra/consola' * const otelReporter = createOpenTelemetryReporter({ * endpoint: 'http://localhost:4318/v1/logs', * serviceName: 'my-app', * serviceVersion: '1.0.0' * }) * consola.addReporter(otelReporter) */ export const createOpenTelemetryReporter = (options = {}) => { return new OpenTelemetryReporter(options) }