@nitra/consola
Version:
consola with filename
319 lines (288 loc) • 10 kB
JavaScript
// 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)
}