diff --git a/packages/conf/DittoConf.ts b/packages/conf/DittoConf.ts index 59a3fde4..09e6eed2 100644 --- a/packages/conf/DittoConf.ts +++ b/packages/conf/DittoConf.ts @@ -238,6 +238,26 @@ export class DittoConf { }; } + /** + * The logging configuration for the Ditto server. The config is derived from + * the DEBUG environment variable and it is parsed as follows: + * + * `DEBUG='::comma-separated scopes to show'`. + * If the scopes are empty (e.g. in 'pretty:warn:', then all scopes are shown.) + */ + get logConfig(): { + fmt: 'jsonl' | 'pretty'; + level: string; + scopes: string[]; + } { + const [fmt = 'jsonl', level = 'debug', scopes = ''] = (this.env.get('LOG_CONFIG') || '').split(':'); + return { + fmt: fmt === 'jsonl' ? fmt : 'pretty', + level, + scopes: scopes.split(',').filter(Boolean), + }; + } + /** nostr.build API endpoint when the `nostrbuild` uploader is used. */ get nostrbuildEndpoint(): string { return this.env.get('NOSTRBUILD_ENDPOINT') || 'https://nostr.build/api/v2/upload/files'; diff --git a/packages/db/KyselyLogger.ts b/packages/db/KyselyLogger.ts index 333e4285..6d0752e6 100644 --- a/packages/db/KyselyLogger.ts +++ b/packages/db/KyselyLogger.ts @@ -14,7 +14,7 @@ export const KyselyLogger: Logger = (event) => { dbQueryDurationHistogram.observe(duration); if (event.level === 'query') { - logi({ level: 'debug', ns: 'ditto.sql', sql, parameters: parameters as LogiValue, duration }); + logi({ level: 'trace', ns: 'ditto.sql', sql, parameters: parameters as LogiValue, duration }); } if (event.level === 'error') { diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index b4ecbaec..857bae60 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -152,6 +152,8 @@ import dittoNamesRoute from '@/routes/dittoNamesRoute.ts'; import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts'; import pleromaStatusesRoute from '@/routes/pleromaStatusesRoute.ts'; import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; +import { logi } from '@soapbox/logi'; +import { createLogiHandler } from '@/utils/logi.ts'; export interface AppEnv extends DittoEnv { Variables: DittoEnv['Variables'] & { @@ -179,6 +181,7 @@ type AppMiddleware = MiddlewareHandler; type AppController

= Handler>; const conf = new DittoConf(Deno.env); +logi.handler = createLogiHandler(conf, logi.handler); startSentry(conf); diff --git a/packages/ditto/utils/logi.ts b/packages/ditto/utils/logi.ts new file mode 100644 index 00000000..4b764201 --- /dev/null +++ b/packages/ditto/utils/logi.ts @@ -0,0 +1,77 @@ +import type { LogiHandler, LogiLog, LogiValue } from '@soapbox/logi'; +import { DittoConf } from '@ditto/conf'; + +type Level = LogiLog['level']; + +const levels: Level[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'critical']; + +const lowerLevels: Record = levels.reduce((acc, level, index) => { + acc[level] = levels.slice(index); + return acc; +}, {} as Record); + +const colors: Record = { + trace: 'grey', + debug: 'white', + info: 'blue', + warn: 'yellow', + error: 'orange', + fatal: 'red', + critical: 'red', +}; + +const levelSet = new Set(levels); +const isLevel = (str: string): str is Level => levelSet.has(str as Level); + +const prettyPrint = (msg: LogiValue): string => { + const message = msg || ''; + const type = typeof message; + switch (type) { + case 'string': + case 'bigint': + case 'number': + case 'boolean': + return message.toString(); + case 'function': + case 'symbol': + case 'undefined': + return `<${type}>`; + case 'object': + if (message === null) return ''; + return JSON.stringify(message, (_, v) => { + if (Array.isArray(v)) { + return `[${v.map((itm) => JSON.stringify(itm)).join(', ')}]`; + } + if (typeof v === 'string') return `\`${v}\``; + return v; + }, 2) + .replaceAll('\\"', '"') + .replace(/^"/, '') + .replace(/"$/, ''); + } +}; + +const pair = (key: string, value: LogiValue | undefined) => { + return `${key}: ${prettyPrint(value || '')}`; +}; + +export const createLogiHandler = (conf: DittoConf, defaultHandler: LogiHandler) => (log: LogiLog) => { + const { fmt, level, scopes } = conf.logConfig; + if (fmt === 'jsonl') return defaultHandler(log); + if (!isLevel(level)) throw new Error(`Invalid log level ${level} specified`); + if (!lowerLevels[level].includes(log.level)) return; + if (scopes.length && !scopes.some((scope) => scope.startsWith(log.ns))) return; + const message = prettyPrint(log.message || log.msg || ''); + const remaining = Object.entries(log) + .filter(([key]) => !['ns', 'level', 'message', 'msg'].includes(key)); + + console.group( + `%c${log.level.toUpperCase()} %c${log.ns} %c${message || ''}`, + `color: ${colors[log.level]}; font-weight: bold`, + 'font-weight: normal; color: yellow', + 'color: unset', + ); + + if (remaining.length) console.log(remaining.map((itm) => pair(...itm)).join('\n')); + console.groupEnd(); +};