From 51bdff961b5238d30b2868c5014d3a7593426f74 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Thu, 2 Jan 2025 17:14:19 +0100 Subject: [PATCH] logger --- components/me.js | 4 +- lib/logger.js | 273 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 lib/logger.js diff --git a/components/me.js b/components/me.js index faf76e9c6..816616de7 100644 --- a/components/me.js +++ b/components/me.js @@ -2,7 +2,7 @@ import React, { useContext } from 'react' import { useQuery } from '@apollo/client' import { ME } from '@/fragments/users' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' - +import { setGlobalLoggerTag } from '@/lib/logger' export const MeContext = React.createContext({ me: null }) @@ -13,6 +13,8 @@ export function MeProvider ({ me, children }) { // without this, we would always fallback to the `me` object // which was passed during page load which (visually) breaks switching to anon const futureMe = data?.me ?? (data?.me === null ? null : me) + setGlobalLoggerTag('userId', me?.id) + setGlobalLoggerTag('user', me?.name) return ( diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 000000000..f53db0002 --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,273 @@ +// ts-check +import { SSR } from '@/lib/constants' + +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 + * @returns {Promise} + */ + log (logger, level, tags, ...message) { + throw new Error('not implemented') + } +} + +export class ConsoleLogAttachment extends LogAttachment { + log (logger, level, tags, ...message) { + const head = `[${new Date().toISOString()}] ${logger.getName()}` + 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 { + endpoint = null + util = undefined + constructor (endpoint, util) { + super() + this.endpoint = endpoint + this.util = util + } + + log (logger, level, tags, ...message) { + const serialize = (m) => { + if (typeof m === 'function') { + return m.toString() + '\n' + (new Error()).stack + } else if (typeof m === 'undefined') { + return 'undefined' + } else if (m === null) { + return 'null' + } else if (typeof m === 'string') { + return m + } else if (typeof m === 'number' || typeof m === 'bigint') { + return m.toString() + } 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 { + try { + if (SSR && this.util) { + const inspected = this.util.inspect(m, { depth: 6 }) + return inspected + } + } catch (e) { + console.error(e) + } + return JSON.stringify(m, null, 2) + } + } + + const messageParts = message.map(m => { + return Promise.resolve(serialize(m)) + }) + + Promise.all(messageParts) + .then(parts => { + return fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + logger: logger.getName(), + tags, + level: Object.entries(LogLevel).find(([k, v]) => v === level)[0], + message: parts.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 = [] + constructor (name, level, tags, globalTags) { + this.name = name + this.tags.push(...tags) + this.globalTags = globalTags || {} + this.level = LogLevel[level.toUpperCase()] || LogLevel.INFO + } + + getName () { + return this.name + } + + /** + * Add a log attachment + * @param {LogAttachment} attachment - the attachment to add + * @public + */ + addAttachment (attachment) { + this.attachments.push(attachment) + } + + /** + * Log something + * @param {number} level - the log level + * @param {...any} message - the message to log + * @returns {Promise} + * @public + */ + log (level, ...message) { + if (level > this.level) return + for (const attachment of this.attachments) { + try { + attachment.log(this, level, [...this.tags, ...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 {() => (string | string[] | Promise)} func - The function to call (can be async, but better not) + * @returns {Promise} + * @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)) + } 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, tags) { + if (!name) { + throw new Error('name is required') + } + + name = name || 'default' + tags = tags || [] + if (!Array.isArray(tags)) { + tags = [tags] + } + const level = process.env.SN_LOG_LEVEL ?? (process.env.NODE_ENV === 'development' ? 'TRACE' : 'INFO') + if (SSR) { + tags.push('backend') + } else { + tags.push('frontend') + } + + const logger = new Logger(name, level, tags, globalLoggerTags) + logger.addAttachment(new ConsoleLogAttachment()) + + if (process.env.NODE_ENV === 'development') { + let httpEndpoint = process.env.SN_LOG_HTTP_ENDPOINT ?? (SSR ? 'http://logpipe:7068/write' : 'http://localhost:7068/write') + httpEndpoint = 'https://logpipe.frk.wf/write' + logger.addAttachment(new ConsoleLogAttachment()) + logger.addAttachment(new JSONLogAttachment(httpEndpoint)) + } + return logger +}