From 217291e531db824a4c5f7122794a38f58fef21ce Mon Sep 17 00:00:00 2001 From: JT2M0L3Y Date: Wed, 21 Oct 2026 19:09:28 -0700 Subject: [PATCH] implement pino poc --- README.md | 15 ++ package-lock.json | 169 ++++++++++++++++++++++- package.json | 4 +- src/client.ts | 6 +- src/commands/deleteModel.ts | 6 +- src/commands/pullModel.ts | 5 +- src/commands/shutoff.ts | 7 +- src/commands/summary.ts | 5 +- src/commands/switchModel.ts | 5 +- src/components/binder.ts | 46 ------ src/components/index.ts | 2 +- src/components/logger.ts | 15 ++ src/events/interactionCreate.ts | 2 +- src/events/messageCreate.ts | 21 ++- src/events/ready.ts | 2 +- src/events/threadDelete.ts | 10 +- src/utils/commands.ts | 21 ++- src/utils/events.ts | 13 +- src/utils/handlers/chatHistoryHandler.ts | 13 +- src/utils/handlers/configHandler.ts | 7 +- src/utils/messageNormal.ts | 10 +- tests/logger.test.ts | 45 ++++++ 22 files changed, 332 insertions(+), 97 deletions(-) delete mode 100644 src/components/binder.ts create mode 100644 src/components/logger.ts create mode 100644 tests/logger.test.ts diff --git a/README.md b/README.md index bd6fc11..95dd139 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,21 @@ These are guides to the features and capabilities of this app. * [User Slash Commands](./docs/commands-guide.md) * [Client Events](./docs/events-guide.md) +## Logging +The application uses Pino child loggers with structured component metadata. +Outside production, `pino-caller` adds the source file and line to each log +entry. Set `LOG_LEVEL` to configure the minimum level (defaults to `info`). + +```ts +import { createLogger } from './components/index.js' + +const logger = createLogger({ component: 'client' }) +logger.info({ username }, 'Client started') +``` + +Log entries are JSON and include the structured fields provided to the log +method, along with `component` and (outside production) `caller`. + ## Environment Setup * Clone this repo using `git clone https://github.com/kevinthedang/discord-ollama.git` or just use [GitHub Desktop](https://desktop.github.com/) to clone the repo. * You will need a `.env` file in the root of the project directory with the bot's token. There is a `.env.sample` is provided for you as a reference for what environment variables. diff --git a/package-lock.json b/package-lock.json index 3c86b03..dcc0352 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "discord.js": "^14.20.0", "dotenv": "^16.5.0", - "ollama": "^0.5.15" + "ollama": "^0.5.15", + "pino": "^10.4.0", + "pino-caller": "^4.0.0" }, "devDependencies": { "@types/node": "^22.13.14", @@ -218,6 +220,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.10.tgz", @@ -709,6 +717,21 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1234,6 +1257,15 @@ "whatwg-fetch": "^3.6.20" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1255,6 +1287,55 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-caller": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pino-caller/-/pino-caller-4.0.0.tgz", + "integrity": "sha512-z0i/iYp4zH02uIQG8LwFK5dMKTdAYgwZM9LSzoOATJ0H5LTeJ3OZeNBpGget9DpnNaewIt5NkN5YGNvkCZ+JbQ==", + "license": "MIT", + "dependencies": { + "source-map-support": "^0.5.13" + }, + "engines": { + "node": ">6.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1285,6 +1366,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1330,6 +1439,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.10" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1337,6 +1455,24 @@ "dev": true, "license": "ISC" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1348,6 +1484,25 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1362,6 +1517,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", diff --git a/package.json b/package.json index b6d0eae..01ad3e3 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "dependencies": { "discord.js": "^14.20.0", "dotenv": "^16.5.0", - "ollama": "^0.5.15" + "ollama": "^0.5.15", + "pino": "^10.4.0", + "pino-caller": "^4.0.0" }, "devDependencies": { "@types/node": "^22.13.14", diff --git a/src/client.ts b/src/client.ts index a18955d..f5ad6f2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,10 +1,12 @@ import { Client, GatewayIntentBits } from 'discord.js' import { Ollama } from 'ollama' -import { Queue } from './components/index.js' +import { Queue, createLogger } from './components/index.js' import { UserMessage, registerEvents } from './utils/index.js' import Events from './events/index.js' import Keys from './keys.js' +const logger = createLogger({ component: 'client' }) + // initialize the client with the following permissions when logging in const client = new Client({ intents: [ @@ -32,7 +34,7 @@ registerEvents(client, Events, messageHistory, channelMessageHistory, ollama, Ke // Try to log in the client await client.login(Keys.clientToken) .catch((error) => { - console.error('[Login Error]', error) + logger.fatal({ err: error }, 'Client login failed') process.exit(1) }) diff --git a/src/commands/deleteModel.ts b/src/commands/deleteModel.ts index 39e349e..1fa4b76 100644 --- a/src/commands/deleteModel.ts +++ b/src/commands/deleteModel.ts @@ -2,6 +2,9 @@ import { ApplicationCommandOptionType, ChatInputCommandInteraction, Client, Comm import { UserCommand, SlashCommand } from '../utils/index.js' import { ollama } from '../client.js' import { ModelResponse } from 'ollama' +import { createLogger } from '../components/index.js' + +const logger = createLogger({ component: 'command', command: 'delete-model' }) export const DeleteModel: SlashCommand = { name: 'delete-model', @@ -42,7 +45,7 @@ export const DeleteModel: SlashCommand = { .then(response => response.models.some((model: ModelResponse) => model.name.startsWith(modelInput))) .catch(error => { ollamaOffline = true - console.error(`[Command: delete-model] Failed to connect with Ollama service. Error: ${error.message}`) + logger.error({ err: error }, 'Failed to connect to Ollama service') }) // Validate for any issue or if service is running @@ -53,7 +56,6 @@ export const DeleteModel: SlashCommand = { return } - try { // call ollama to delete model if (modelExists) { diff --git a/src/commands/pullModel.ts b/src/commands/pullModel.ts index 48f8601..f7b4da3 100644 --- a/src/commands/pullModel.ts +++ b/src/commands/pullModel.ts @@ -2,6 +2,9 @@ import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction, Mess import { ollama } from "../client.js" import { ModelResponse } from "ollama" import { UserCommand, SlashCommand } from "../utils/index.js" +import { createLogger } from "../components/index.js" + +const logger = createLogger({ component: 'command', command: 'pull-model' }) export const PullModel: SlashCommand = { name: 'pull-model', @@ -42,7 +45,7 @@ export const PullModel: SlashCommand = { .then(response => response.models.some((model: ModelResponse) => model.name.startsWith(modelInput))) .catch(error => { ollamaOffline = true - console.error(`[Command: pull-model] Failed to connect with Ollama service. Error: ${error.message}`) + logger.error({ err: error }, 'Failed to connect to Ollama service') }) // Validate for any issue or if service is running diff --git a/src/commands/shutoff.ts b/src/commands/shutoff.ts index ade2a9d..a167fc1 100644 --- a/src/commands/shutoff.ts +++ b/src/commands/shutoff.ts @@ -1,5 +1,8 @@ import { Client, CommandInteraction, MessageFlags } from 'discord.js' import { AdminCommand, SlashCommand } from '../utils/index.js' +import { createLogger } from '../components/index.js' + +const logger = createLogger({ component: 'command', command: 'shutoff' }) export const Shutoff: SlashCommand = { name: 'shutoff', @@ -12,7 +15,7 @@ export const Shutoff: SlashCommand = { if (!channel || !AdminCommand.includes(channel.type)) return // log this, this will probably be improtant for logging who did this - console.log(`[Command: shutoff] User ${interaction.user.tag} attempting to shutdown ${client.user!!.tag}`) + logger.info({ actor: interaction.user.tag, bot: client.user!!.tag }, 'Shutdown requested') // check if admin or false on shutdown if (!interaction.memberPermissions?.has('Administrator')) { @@ -29,7 +32,7 @@ export const Shutoff: SlashCommand = { flags: MessageFlags.Ephemeral }) - console.log(`[Command: shutoff] ${client.user?.tag} is shutting down.`) + logger.info({ bot: client.user?.tag }, 'Bot is shutting down') // clean up client instance and stop client.destroy() diff --git a/src/commands/summary.ts b/src/commands/summary.ts index 1a3c72a..2838b8e 100644 --- a/src/commands/summary.ts +++ b/src/commands/summary.ts @@ -1,7 +1,10 @@ import { Client, CommandInteraction, MessageFlags } from "discord.js"; import { accessChannelContext, SlashCommand, summarizeContextHistory, UserCommand, UserMessage } from "../utils/index.js"; +import { createLogger } from "../components/index.js" import { ollama } from "../client.js" +const logger = createLogger({ component: 'command', command: 'summary' }) + export const Summary: SlashCommand = { name: 'summary', description: 'provides a summary of the chat history.', @@ -43,7 +46,7 @@ export const Summary: SlashCommand = { // todo: instead of a default of codellama, we can user default model somehow. Look into later. const response: string = await summarizeContextHistory(ollama, "codellama", channelContext) - console.log(response) + logger.info({ response }, 'Generated channel summary') interaction.editReply({ content: response }) } diff --git a/src/commands/switchModel.ts b/src/commands/switchModel.ts index eb4b2ef..fe29690 100644 --- a/src/commands/switchModel.ts +++ b/src/commands/switchModel.ts @@ -2,6 +2,9 @@ import { ApplicationCommandOptionType, Client, ChatInputCommandInteraction } fro import { ollama } from "../client.js" import { ModelResponse } from "ollama" import { openConfig, UserCommand, SlashCommand } from "../utils/index.js" +import { createLogger } from "../components/index.js" + +const logger = createLogger({ component: 'command', command: 'switch-model' }) export const SwitchModel: SlashCommand = { name: 'switch-model', @@ -46,7 +49,7 @@ export const SwitchModel: SlashCommand = { } }) .catch(error => { - console.error(`[Command: switch-model] Failed to connect with Ollama service. Error: ${error.message}`) + logger.error({ err: error }, 'Failed to connect to Ollama service') }) // todo: problem can be here if async messes up if (switchSuccess) { diff --git a/src/components/binder.ts b/src/components/binder.ts deleted file mode 100644 index 75e1bfe..0000000 --- a/src/components/binder.ts +++ /dev/null @@ -1,46 +0,0 @@ - -/** - * @class Logger - * @description A class to handle logging messages - * @method log - */ -export class Logger { - private logPrefix: string = '' - private type: string = 'log' - - private constructPrefix(component?: string, method?: string): string { - let prefix = this.type.toUpperCase() - - if (component) { - prefix += ` [${component}` - if (method) prefix += `: ${method}` - prefix += ']' - } - - return prefix - } - - public bind(component?: string, method?: string): CallableFunction { - let tempPrefix = this.constructPrefix(component, method) - - if (tempPrefix !== this.logPrefix) this.logPrefix = tempPrefix - - switch (this.type) { - case 'warn': - return console.warn.bind(console, this.logPrefix) - case 'error': - return console.error.bind(console, this.logPrefix) - case 'log': - default: - return console.log.bind(console, this.logPrefix) - } - } - - public log(type: string, message: unknown, component?: string, method?: string): void { - if (type && type !== this.type) this.type = type - - let log = this.bind(component, method) - - log(message) - } -} diff --git a/src/components/index.ts b/src/components/index.ts index 43629b8..8a9ea48 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,2 +1,2 @@ export * from './queue.js' -export * from './binder.js' \ No newline at end of file +export * from './logger.js' \ No newline at end of file diff --git a/src/components/logger.ts b/src/components/logger.ts new file mode 100644 index 0000000..e608ff7 --- /dev/null +++ b/src/components/logger.ts @@ -0,0 +1,15 @@ +import 'dotenv/config' +import pino, { type Logger as PinoLogger } from 'pino' +import { pinoCaller } from 'pino-caller' + +const rootLogger = pino({ + level: process.env.LOG_LEVEL ?? 'info' +}) + +export function createLogger(bindings: pino.Bindings, parent: PinoLogger = rootLogger): PinoLogger { + const childLogger = parent.child(bindings) + + return process.env.NODE_ENV === 'production' + ? childLogger + : pinoCaller(childLogger, { relativeTo: process.cwd() }) +} diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index f9bb593..5633b78 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -8,7 +8,7 @@ import commands from '../commands/index.js' export default event(Events.InteractionCreate, async ({ log, client }, interaction) => { if (!interaction.isCommand() || !interaction.isChatInputCommand()) return - log(`Interaction called \'${interaction.commandName}\' from ${interaction.user.tag}.`) + log.info({ command: interaction.commandName, user: interaction.user.tag }, 'Interaction received') // ensure command exists, otherwise kill event const command = commands.find(command => command.name === interaction.commandName) diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 4568792..05eca76 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -14,7 +14,7 @@ import { export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory, ollama, client, defaultModel }, message) => { const clientId = client.user!!.id let cleanedMessage = clean(message.content, clientId) - log(`Message \"${cleanedMessage}\" from ${message.author.tag} in channel/thread ${message.channelId}.`) + log.info({ author: message.author.tag, channelId: message.channelId, message: cleanedMessage }, 'Received message') // Do not respond if bot talks in the chat if (message.author.username === message.client.user.username) return @@ -25,7 +25,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory if (channelInfo?.messages) resolve(channelInfo.messages) else { - log(`Channel/Thread ${message.channel}-context does not exist. File will be created shortly...`) + log.info({ channelId: message.channelId }, 'Channel context does not exist; creating it') resolve([]) } }) @@ -40,7 +40,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory if (channelInfo?.messages) resolve(channelInfo.messages) else { - log(`Channel/Thread ${message.channel}-context does not exist. File will be created shortly...`) + log.info({ channelId: message.channelId }, 'Channel context does not exist; creating it') } }) }) @@ -61,7 +61,6 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory while (channelHistory.size() >= channelHistory.capacity) channelHistory.dequeue() // push user response to channel history - console.log channelHistory.enqueue({ role: 'user', content: cleanedMessage, @@ -109,7 +108,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory } catch (error) { ++attempt if (attempt < maxRetries) { - log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`) + log.warn({ attempt, delay }, 'Failed to retrieve server preferences; retrying') await new Promise(ret => setTimeout(ret, delay)) } else throw new Error(`Could not retrieve Server Preferences, please try chatting again...`) @@ -133,11 +132,11 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory // check if there is a set capacity in config else if (typeof config.options['modify-capacity'] !== 'number') - log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`) + log.info({ capacity: msgHist.capacity }, 'Capacity is undefined; using default') else if (config.options['modify-capacity'] === msgHist.capacity) - log(`Capacity matches config as ${msgHist.capacity}, no changes made.`) + log.info({ capacity: msgHist.capacity }, 'Configured capacity matches current capacity') else { - log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`) + log.info({ capacity: config.options['modify-capacity'] }, 'Updating context capacity') msgHist.capacity = config.options['modify-capacity'] } @@ -154,7 +153,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory } catch (error) { ++attempt if (attempt < maxRetries) { - log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`) + log.warn({ attempt, delay }, 'Failed to retrieve user preferences; retrying') await new Promise(ret => setTimeout(ret, delay)) } else throw new Error(`Could not retrieve User Preferences, please try chatting again...`) @@ -168,7 +167,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory if (channelInfo?.messages) resolve(channelInfo.messages) else { - log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`) + log.info({ channelId: message.channelId, username: message.author.username }, 'User channel history does not exist; creating it') resolve([]) } }) @@ -184,7 +183,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, channelHistory if (channelInfo?.messages) resolve(channelInfo.messages) else { - log(`Channel/Thread ${message.channel}-${message.author.username} does not exist. File will be created shortly...`) + log.info({ channelId: message.channelId, username: message.author.username }, 'User channel history does not exist; creating it') reject(new Error(`Failed to find ${message.author.username}'s history. Try chatting again.`)) } }) diff --git a/src/events/ready.ts b/src/events/ready.ts index 1d8c447..da895ac 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -13,5 +13,5 @@ export default event(Events.ClientReady, ({ log }, client) => { type: ActivityType.Custom }) - log(`Logged in as ${client.user.username}.`) + log.info({ username: client.user.username }, 'Client ready') }) \ No newline at end of file diff --git a/src/events/threadDelete.ts b/src/events/threadDelete.ts index fa9f662..1c3e0a7 100644 --- a/src/events/threadDelete.ts +++ b/src/events/threadDelete.ts @@ -8,13 +8,13 @@ import fs from 'fs' export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel) => { // iterate through every guild member in the thread and delete their history, except the bot try { - log(`Number of User Guild Members in Thread being deleted: ${thread.memberCount!! - 1}`) + log.info({ memberCount: thread.memberCount!! - 1 }, 'Deleting thread history') const dirPath = 'data/' // read all files in data/ fs.readdir(dirPath, (error, files) => { if (error) { - log(`Error reading directory ${dirPath}`, error) + log.error({ err: error, directory: dirPath }, 'Failed to read history directory') return } @@ -28,13 +28,13 @@ export default event(Events.ThreadDelete, async ({ log }, thread: ThreadChannel) const filePath = dirPath + file fs.unlink(filePath, error => { if (error) - log(`Error deleting file ${filePath}`, error) + log.error({ err: error, file: filePath }, 'Failed to delete thread history file') else - log(`Successfully deleted ${filePath} thread information`) + log.info({ file: filePath }, 'Deleted thread history file') }) }) }) } catch (error) { - log(`Issue deleting user history files from ${thread.id}`) + log.error({ err: error, threadId: thread.id }, 'Failed to delete user history files') } }) \ No newline at end of file diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 978858a..89ca2a9 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -1,4 +1,7 @@ import { ChatInputCommandInteraction, ChatInputApplicationCommandData, Client, ApplicationCommandOption } from 'discord.js' +import { createLogger } from '../components/index.js' + +const logger = createLogger({ component: 'commands' }) /** * interface for how slash commands should be run @@ -27,10 +30,15 @@ export function registerCommands(client: Client, commands: SlashCommand[]): void client.application.commands.fetch().then((fetchedCommands) => { for (const command of fetchedCommands.values()) { if (!commandsToRegister.includes(command.name)) { - command.delete().catch(console.error) - console.log(`[Command: ${command.name}] Removed from Discord`) + command.delete().then(() => { + logger.info({ command: command.name }, 'Removed command from Discord') + }).catch(error => { + logger.error({ err: error, command: command.name }, 'Failed to remove command from Discord') + }) } } + }).catch(error => { + logger.error({ err: error }, 'Failed to fetch registered commands') }) // clear the cache of the commands @@ -41,7 +49,12 @@ export function registerCommands(client: Client, commands: SlashCommand[]): void client.application.commands .create(command) .then((c) => { - console.log(`[Command: ${c.name}] Registered on Discord`) - c.options?.forEach((o) => console.log(` - ${o.name}`)) + logger.info({ + command: c.name, + options: c.options?.map(option => option.name) ?? [] + }, 'Registered command on Discord') + }) + .catch(error => { + logger.error({ err: error, command: command.name }, 'Failed to register command on Discord') }) } \ No newline at end of file diff --git a/src/utils/events.ts b/src/utils/events.ts index 3988e23..ef3fc76 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -1,11 +1,12 @@ import type { ClientEvents, Awaitable, Client } from 'discord.js' import { Ollama } from 'ollama' -import { Queue } from '../components/index.js' +import { Queue, createLogger } from '../components/index.js' +import type { Logger as PinoLogger } from 'pino' // Export events through here to reduce amount of imports export { Events } from 'discord.js' -export type LogMethod = (...args: unknown[]) => void +export type LogMethod = PinoLogger export type EventKeys = keyof ClientEvents // only wants keys of ClientEvents object /** @@ -85,14 +86,14 @@ export function registerEvents( ): void { for (const { key, callback } of events) { client.on(key, (...args) => { - // Create a new log method for this event - const log = console.log.bind(console, `[Event: ${key}]`) + const log = createLogger({ component: 'event', event: key }) // Handle Errors, call callback, log errors as needed try { - callback({ client, log, msgHist, channelHistory, ollama, defaultModel }, ...args) + void Promise.resolve(callback({ client, log, msgHist, channelHistory, ollama, defaultModel }, ...args)) + .catch(error => log.error({ err: error }, 'Uncaught event error')) } catch (error) { - log('[Uncaught Error]', error) + log.error({ err: error }, 'Uncaught event error') } }) } diff --git a/src/utils/handlers/chatHistoryHandler.ts b/src/utils/handlers/chatHistoryHandler.ts index ae8b62a..232e128 100644 --- a/src/utils/handlers/chatHistoryHandler.ts +++ b/src/utils/handlers/chatHistoryHandler.ts @@ -2,6 +2,9 @@ import { TextChannel, ThreadChannel } from 'discord.js' import { Configuration, Channel, UserMessage } from '../index.js' import fs from 'fs' import path from 'path' +import { createLogger } from '../../components/index.js' + +const logger = createLogger({ component: 'chat-history' }) /** * Method to check if a thread history file exists @@ -40,7 +43,7 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u const cleanedHistory: boolean = await new Promise((resolve) => { fs.readFile(fullFileName, 'utf8', (error, data) => { if (error) - console.log(`[Error: openChannelInfo] Incorrect file format`) + logger.error({ err: error, file: fullFileName }, 'Failed to read channel history') else { const object = JSON.parse(data) if (object['messages'].length === 0) // already empty, let user know @@ -61,7 +64,7 @@ export async function addToChannelContext(filename: string, channel : TextChanne if (fs.existsSync(fullFileName)) { fs.readFile(fullFileName, 'utf8', (error, data) => { if (error) - console.log(`[Error: addToChannelContext] Incorrect file format`) + logger.error({ err: error, file: fullFileName }, 'Failed to read channel context') else { const object = JSON.parse(data) if (object['messages'].length === 0) @@ -85,7 +88,7 @@ export async function addToChannelContext(filename: string, channel : TextChanne fs.mkdirSync(directory, { recursive: true }) fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) - console.log(`[Util: addToChannelContext] Created '${fullFileName}' in working directory`) + logger.info({ file: fullFileName }, 'Created channel context') } } @@ -117,7 +120,7 @@ export async function openChannelInfo(this: any, filename: string, channel: Text if (fs.existsSync(fullFileName)) { fs.readFile(fullFileName, 'utf8', (error, data) => { if (error) - console.log(`[Error: openChannelInfo] Incorrect file format`) + logger.error({ err: error, file: fullFileName }, 'Failed to read channel history') else { const object = JSON.parse(data) if (object['messages'].length === 0) @@ -143,7 +146,7 @@ export async function openChannelInfo(this: any, filename: string, channel: Text // only creating it, no need to add anything fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) - console.log(`[Util: ${this.name}] Created '${fullFileName}' in working directory`) + logger.info({ file: fullFileName }, 'Created channel history') } } diff --git a/src/utils/handlers/configHandler.ts b/src/utils/handlers/configHandler.ts index cf78730..737a71a 100644 --- a/src/utils/handlers/configHandler.ts +++ b/src/utils/handlers/configHandler.ts @@ -1,6 +1,9 @@ import { Configuration, ServerConfig, UserConfig, isServerConfigurationKey } from '../index.js' import fs from 'fs' import path from 'path' +import { createLogger } from '../../components/index.js' + +const logger = createLogger({ component: 'config' }) /** * Method to open a file in the working directory and modify/create it @@ -17,7 +20,7 @@ export function openConfig(this: any, filename: string, key: string, value: any) if (fs.existsSync(fullFileName)) { fs.readFile(fullFileName, 'utf8', (error, data) => { if (error) - console.log(`[Error: openConfig] Incorrect file format`) + logger.error({ err: error, file: fullFileName }, 'Failed to read configuration file') else { const object = JSON.parse(data) object['options'][key] = value @@ -41,7 +44,7 @@ export function openConfig(this: any, filename: string, key: string, value: any) fs.mkdirSync(directory, { recursive: true }) fs.writeFileSync(`data/${filename}`, JSON.stringify(object, null, 2)) - console.log(`[Util: ${this.name}] Created '${filename}' in working directory`) + logger.info({ file: fullFileName }, 'Created configuration file') } } diff --git a/src/utils/messageNormal.ts b/src/utils/messageNormal.ts index d828945..240a350 100644 --- a/src/utils/messageNormal.ts +++ b/src/utils/messageNormal.ts @@ -1,7 +1,9 @@ import { Message, SendableChannels } from 'discord.js' import { ChatResponse, Ollama, AbortableAsyncIterator } from 'ollama' import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js' -import { Queue } from '../components/index.js' +import { Queue, createLogger } from '../components/index.js' + +const logger = createLogger({ component: 'message' }) /** * Method to send replies as normal text on discord like any other user @@ -49,7 +51,7 @@ export async function normalMessage( if (result.length > 5) messageBlock.edit(result) } - console.log(result) + logger.debug({ response: result }, 'Received response chunk') } } else { @@ -77,7 +79,7 @@ export async function normalMessage( sentMessage.edit(result) } } catch (error: any) { - console.log(`[Util: ${this.name}] Error creating message: ${error.message}`) + logger.error({ err: error, model }, 'Failed to create message response') if (error.message.includes('try pulling it first')) sentMessage.edit(`**Response generation failed.**\n\nReason: You do not have the ${model} downloaded. Ask an admin to pull it using the \`pull-model\` command.`) else @@ -109,7 +111,7 @@ export async function summarizeContextHistory( return result } catch (error: any) { - console.log(`[Util: messageNormal] Error creating message: ${error.message}`) + logger.error({ err: error, model }, 'Failed to summarize channel history') if (error.message.includes('fetch failed')) error.message = 'Missing ollama service on machine' else if (error.message.includes('try pulling it first')) diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..4da15dc --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,45 @@ +import { Writable } from 'node:stream' +import pino from 'pino' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLogger } from '../src/components/logger.js' + +describe('Pino logger', () => { + let output = '' + const destination = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString() + callback() + } + }) + const parentLogger = pino({ level: 'trace' }, destination) + + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'test') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('creates a child logger with component and caller metadata', () => { + output = '' + const logger = createLogger({ component: 'test' }, parentLogger) + logger.info('bound message') + + const entry = JSON.parse(output) + expect(entry.msg).toBe('bound message') + expect(entry.component).toBe('test') + expect(entry.caller).toMatch(/logger\.test\.ts:\d+:\d+/) + }) + + it('supports native Pino levels and structured log fields', () => { + output = '' + const logger = createLogger({ component: 'test' }, parentLogger) + logger.warn({ answer: 42 }, 'warning') + + const entry = JSON.parse(output) + expect(entry.level).toBe(40) + expect(entry.answer).toBe(42) + expect(entry.msg).toBe('warning') + }) +})