diff --git a/README.md b/README.md index d67e2973f..76e0eedba 100644 --- a/README.md +++ b/README.md @@ -79,24 +79,25 @@ COMMANDS set_balance set the balance of a nym lightning: - fund pay a bolt11 for funding - withdraw create a bolt11 for withdrawal + fund pay a bolt11 for funding + withdraw create a bolt11 for withdrawal db: - psql open psql on db - prisma run prisma commands + psql open psql on db + prisma run prisma commands dev: - pr fetch and checkout a pr - lint run linters - test run tests + pr fetch and checkout a pr + lint run linters + test run tests + logger filterable logs from app logger other: - cli service cli passthrough - open open service GUI in browser - onion service onion address - cert service tls cert - compose docker compose passthrough + cli service cli passthrough + open open service GUI in browser + onion service onion address + cert service tls cert + compose docker compose passthrough ``` ### Modifying services diff --git a/docker-compose.yml b/docker-compose.yml index 31825444e..5e85c153b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -78,6 +78,23 @@ services: labels: CONNECT: "localhost:3000" cpu_shares: "${CPU_SHARES_IMPORTANT}" + logpipe: + platform: linux/x86_64 + container_name: logpipe + image: ghcr.io/riccardobl/logpipe:0.0.8 + restart: unless-stopped + healthcheck: + <<: *healthcheck + test: ["CMD", "curl", "-f", "http://localhost:7068/health"] + expose: + - "7068:7068" + environment: + - LOGPIPE_DEBUG=true + ports: + - "7068:7068" + tmpfs: + - /tmp + cpu_shares: "${CPU_SHARES_LOW}" capture: container_name: capture build: @@ -687,7 +704,7 @@ services: - sn_lnd - lnd - router_lnd - restart: unless-stopped + restart: unless-stopped command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME} -f label=ofelia.group=payments volumes: - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 000000000..1f83978af --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,327 @@ +// ts-check +import { SSR } from '@/lib/constants' + +const isBrowser = !SSR + +export const LogLevel = { + TRACE: 600, + DEBUG: 500, + INFO: 400, + WARN: 300, + ERROR: 200, + FATAL: 100, + OFF: 0 +} + +/** + * @abstract + */ +export class LogAttachment { + /** + * Log something + * @param {Logger} logger - the logger that called this attachment + * @param {number} level - the log level + * @param {string[]} tags - the tags + * @param {...any} message - the message to log + * @abstract + * @protected + */ + log (logger, level, tags, ...message) { + throw new Error('Method not implemented.') + } +} + +export class ConsoleLogAttachment extends LogAttachment { + level = undefined + /** + * @param {number} level + */ + constructor (level) { + super() + this.level = level + } + + log (logger, level, tags, ...message) { + if (this.level && level > this.level) return + + let head = '' + if (!isBrowser) { + const date = new Date() + const year = date.getFullYear() + const month = ('0' + (date.getMonth() + 1)).slice(-2) + const day = ('0' + date.getDate()).slice(-2) + const hour = ('0' + date.getHours()).slice(-2) + const minute = ('0' + date.getMinutes()).slice(-2) + const second = ('0' + date.getSeconds()).slice(-2) + head += `[${year}-${month}-${day} ${hour}:${minute}:${second}] ` + } + head += `[${logger.name}]` + if (!isBrowser) { + head += ` [${Object.entries(LogLevel).find(([k, v]) => v === level)[0]}]` + } + + const tail = tags.length ? ` ${tags.join(',')}` : '' + if (level <= LogLevel.ERROR) { + console.error(head, ...message, tail) + } else if (level <= LogLevel.WARN) { + console.warn(head, ...message, tail) + } else if (level <= LogLevel.INFO) { + console.info(head, ...message, tail) + } else { + console.log(head, ...message, tail) + } + } +} + +export class JSONLogAttachment extends LogAttachment { + level = undefined + endpoint = null + + constructor (endpoint, level) { + super() + this.endpoint = endpoint + this.level = level + } + + log (logger, level, tags, ...message) { + if (this.level && level > this.level) return + + const serialize = (m) => { + const type = typeof m + if (type === 'function') { + return m.toString() + '\n' + new Error().stack + } else if (type === 'undefined') { + return 'undefined' + } else if (m === null) { + return 'null' + } else if (type === 'string' || type === 'number' || type === 'bigint' || type === 'boolean') { + return String(m) + } else if (m instanceof Error) { + return m.message || m.toString() + } else if (m instanceof ArrayBuffer || m instanceof Uint8Array) { + return 'Buffer:' + Array.prototype.map.call(new Uint8Array(m), (x) => ('00' + x.toString(16)).slice(-2)).join('') + } else if (type === 'object' && Array.isArray(m)) { + return JSON.stringify(m.map(serialize), null, 2) + } else { + try { + const str = m.toString() + if (str !== '[object Object]') return str + } catch (e) { + console.error(e) + } + return JSON.stringify(m, null, 2) + } + } + + const logLevelStr = Object.entries(LogLevel).find(([k, v]) => v === level)[0] + fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + logger: logger.name, + tags, + level: logLevelStr, + message: message.map((m) => serialize(m)).join(' '), + createdAt: new Date().toISOString() + }) + }).catch((e) => console.error('Error in JSONLogAttachment', e)) + } +} + +/** + * A logger. + * Use debug, trace, info, warn, error, fatal to log messages unless you need to do some expensive computation to get the message, + * in that case do it in a function you pass to debugLazy, traceLazy, infoLazy, warnLazy, errorLazy, fatalLazy + * that will be called only if the log level is enabled. + */ +export class Logger { + tags = [] + globalTags = {} + attachments = [] + name = null + level = null + groupTags = [] + + /** + * + * @param {string} name + * @param {number} [level] + * @param {string[]} [tags] + * @param {{[key: string]: string}} [globalTags] + * @param {string[]} [groupTags] + */ + constructor (name, level, tags, globalTags, groupTags) { + this.name = name + this.tags.push(...tags) + this.globalTags = globalTags || {} + this.level = level + if (groupTags) this.groupTags.push(...groupTags) + } + + /** + * Add a log attachment + * @param {LogAttachment} attachment - the attachment to add + * @public + */ + addAttachment (attachment) { + this.attachments.push(attachment) + } + + group (label) { + this.groupTags.push(label) + } + + groupEnd () { + this.groupTags.pop() + } + + fork (label) { + const logger = new Logger(this.name, this.level, [...this.tags], this.globalTags, [...this.groupTags, label]) + for (const attachment of this.attachments) { + logger.addAttachment(attachment) + } + return logger + } + + /** + * Log something + * @param {number} level - the log level + * @param {...any} message - the message to log + * @public + */ + log (level, ...message) { + if (level > this.level) return + for (const attachment of this.attachments) { + try { + attachment.log(this, level, [...this.tags, ...this.groupTags, ...Object.entries(this.globalTags).map(([k, v]) => `${k}:${v}`)], ...message) + } catch (e) { + console.error('Error in log attachment', e) + } + } + } + + /** + * Log something lazily. + * @param {number} level - the log level + * @param {() => (any|Promise)} func - The function to call (can be async, but better not) + * @throws {Error} if func is not a function + * @public + */ + logLazy (level, func) { + if (typeof func !== 'function') { + throw new Error('lazy log needs a function to call') + } + + if (level > this.level) return + + try { + const res = func() + + const _log = (message) => { + message = Array.isArray(message) ? message : [message] + this.log(level, ...message) + } + + if (res instanceof Promise) { + res.then(_log) + .catch((e) => this.error('Error in lazy log', e)) + .catch((e) => console.error('Error in lazy log', e)) + } else { + _log(res) + } + } catch (e) { + this.error('Error in lazy log', e) + } + } + + debug (...message) { + this.log(LogLevel.DEBUG, ...message) + } + + trace (...message) { + this.log(LogLevel.TRACE, ...message) + } + + info (...message) { + this.log(LogLevel.INFO, ...message) + } + + warn (...message) { + this.log(LogLevel.WARN, ...message) + } + + error (...message) { + this.log(LogLevel.ERROR, ...message) + } + + fatal (...message) { + this.log(LogLevel.FATAL, ...message) + } + + debugLazy (func) { + this.logLazy(LogLevel.DEBUG, func) + } + + traceLazy (func) { + this.logLazy(LogLevel.TRACE, func) + } + + infoLazy (func) { + this.logLazy(LogLevel.INFO, func) + } + + warnLazy (func) { + this.logLazy(LogLevel.WARN, func) + } + + errorLazy (func) { + this.logLazy(LogLevel.ERROR, func) + } + + fatalLazy (func) { + this.logLazy(LogLevel.FATAL, func) + } +} + +const globalLoggerTags = {} + +export function setGlobalLoggerTag (key, value) { + if (value === undefined || value === null) { + delete globalLoggerTags[key] + } else { + globalLoggerTags[key] = value + } +} + +export function getLogger (name = 'default', tags = [], level) { + if (!Array.isArray(tags)) tags = [tags] + + let httpEndpoint = !isBrowser ? 'http://logpipe:7068/write' : 'http://localhost:7068/write' + let env = 'production' + + if (typeof process !== 'undefined') { + env = process.env.NODE_ENV || env + httpEndpoint = process.env.SN_LOG_HTTP_ENDPOINT || httpEndpoint + level = level ?? process.env.SN_LOG_LEVEL + } + level = level ?? (env === 'development' ? 'TRACE' : 'INFO') + + if (!isBrowser) { + tags.push('backend') + } else { + tags.push('frontend') + } + + const logger = new Logger(name, LogLevel[level], tags, globalLoggerTags) + + if (env === 'development') { + logger.addAttachment(new ConsoleLogAttachment()) + logger.addAttachment(new JSONLogAttachment(httpEndpoint)) + } else { + logger.addAttachment(new ConsoleLogAttachment()) + } + + return logger +} diff --git a/sndev b/sndev index a0fc1dcc8..cc68b3489 100755 --- a/sndev +++ b/sndev @@ -152,6 +152,25 @@ OPTIONS" docker__compose logs --help | awk '/Options:/{y=1;next}y' } +sndev__logger() { + docker__compose exec -it logpipe bash /app/scripts/stream.sh +} + +sndev__help_logger() { + help=" +filterable logs from app logger + +USAGE + $ sndev logger + +INTERACTIVE COMMANDS + filter=csv-list,of,tags + level= (eg. level=INFO level=ERROR...etc) +" + + echo "$help" +} + sndev__status() { shift if [ $# -eq 0 ]; then @@ -600,24 +619,25 @@ COMMANDS set_balance set the balance of a nym lightning: - fund pay a bolt11 for funding - withdraw create a bolt11 for withdrawal + fund pay a bolt11 for funding + withdraw create a bolt11 for withdrawal db: - psql open psql on db - prisma run prisma commands + psql open psql on db + prisma run prisma commands dev: - pr fetch and checkout a pr - lint run linters - test run tests + pr fetch and checkout a pr + lint run linters + test run tests + logger filterable logs from app logger other: - cli service cli passthrough - open open service GUI in browser - onion service onion address - cert service tls cert - compose docker compose passthrough + cli service cli passthrough + open open service GUI in browser + onion service onion address + cert service tls cert + compose docker compose passthrough " echo "$help" }