From 8645a0a09689f7e992426aeea6b93dd9d81e8799 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 01:03:08 +0400 Subject: [PATCH] feat(observability): add structured pino logging --- .env.example | 16 +++-- apps/bot/package.json | 1 + apps/bot/src/anonymous-feedback.ts | 12 ++++ apps/bot/src/bot.ts | 12 +++- apps/bot/src/config.ts | 21 +++++++ apps/bot/src/index.ts | 77 +++++++++++++++++++----- apps/bot/src/miniapp-auth.ts | 13 ++-- apps/bot/src/miniapp-dashboard.ts | 4 +- apps/bot/src/purchase-topic-ingestion.ts | 26 +++++++- apps/bot/src/reminder-jobs.ts | 11 ++-- bun.lock | 30 +++++++++ packages/config/src/env.ts | 41 ++++++++++--- packages/observability/package.json | 6 ++ packages/observability/src/index.ts | 54 ++++++++++++++++- 14 files changed, 279 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index 84016da..207038a 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ # Core NODE_ENV=development LOG_LEVEL=info -APP_URL=http://localhost:3000 +PORT=3000 -# Database / Supabase +# Database DATABASE_URL=postgres://postgres:postgres@127.0.0.1:54322/postgres + +# Optional Supabase SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key @@ -12,25 +14,29 @@ SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key # Telegram TELEGRAM_BOT_TOKEN=your-telegram-bot-token TELEGRAM_WEBHOOK_SECRET=your-webhook-secret -TELEGRAM_BOT_USERNAME=your_bot_username TELEGRAM_WEBHOOK_PATH=/webhook/telegram TELEGRAM_HOUSEHOLD_CHAT_ID=-1001234567890 TELEGRAM_PURCHASE_TOPIC_ID=777 +TELEGRAM_FEEDBACK_TOPIC_ID=888 # Household HOUSEHOLD_ID=11111111-1111-4111-8111-111111111111 +# Mini app +MINI_APP_ALLOWED_ORIGINS=http://localhost:5173 + # Parsing / AI OPENAI_API_KEY=your-openai-api-key PARSER_MODEL=gpt-4.1-mini -# Monitoring +# Optional monitoring SENTRY_DSN= -# GCP +# Optional GCP / deploy GCP_PROJECT_ID=your-gcp-project-id GCP_REGION=europe-west1 CLOUD_RUN_SERVICE_BOT=household-bot # Scheduler SCHEDULER_SHARED_SECRET=your-scheduler-shared-secret +SCHEDULER_OIDC_ALLOWED_EMAILS=scheduler-invoker@your-project.iam.gserviceaccount.com diff --git a/apps/bot/package.json b/apps/bot/package.json index f58e078..b496210 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -14,6 +14,7 @@ "@household/application": "workspace:*", "@household/db": "workspace:*", "@household/domain": "workspace:*", + "@household/observability": "workspace:*", "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", "google-auth-library": "^10.4.1", diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts index fa5f298..eb75dcb 100644 --- a/apps/bot/src/anonymous-feedback.ts +++ b/apps/bot/src/anonymous-feedback.ts @@ -1,4 +1,5 @@ import type { AnonymousFeedbackService } from '@household/application' +import type { Logger } from '@household/observability' import type { Bot, Context } from 'grammy' function isPrivateChat(ctx: Context): boolean { @@ -33,6 +34,7 @@ export function registerAnonymousFeedback(options: { anonymousFeedbackService: AnonymousFeedbackService householdChatId: string feedbackTopicId: number + logger?: Logger }): void { options.bot.command('anon', async (ctx) => { if (!isPrivateChat(ctx)) { @@ -94,6 +96,16 @@ export function registerAnonymousFeedback(options: { await ctx.reply('Anonymous feedback delivered.') } catch (error) { const message = error instanceof Error ? error.message : 'Unknown Telegram send failure' + options.logger?.error( + { + event: 'anonymous_feedback.post_failed', + submissionId: result.submissionId, + householdChatId: options.householdChatId, + feedbackTopicId: options.feedbackTopicId, + error: message + }, + 'Anonymous feedback posting failed' + ) await options.anonymousFeedbackService.markFailed(result.submissionId, message) await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') } diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 8377090..c4996ac 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -1,6 +1,7 @@ import { Bot } from 'grammy' +import type { Logger } from '@household/observability' -export function createTelegramBot(token: string): Bot { +export function createTelegramBot(token: string, logger?: Logger): Bot { const bot = new Bot(token) bot.command('help', async (ctx) => { @@ -20,7 +21,14 @@ export function createTelegramBot(token: string): Bot { }) bot.catch((error) => { - console.error('Telegram bot error', error.error) + logger?.error( + { + event: 'telegram.bot_error', + updateId: error.ctx?.update.update_id, + error: error.error + }, + 'Telegram bot error' + ) }) return bot diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index bed48f3..d06b8cb 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -1,5 +1,6 @@ export interface BotRuntimeConfig { port: number + logLevel: 'debug' | 'info' | 'warn' | 'error' telegramBotToken: string telegramWebhookSecret: string telegramWebhookPath: string @@ -33,6 +34,25 @@ function parsePort(raw: string | undefined): number { return parsed } +function parseLogLevel(raw: string | undefined): 'debug' | 'info' | 'warn' | 'error' { + if (raw === undefined) { + return 'info' + } + + const normalized = raw.trim().toLowerCase() + + if ( + normalized === 'debug' || + normalized === 'info' || + normalized === 'warn' || + normalized === 'error' + ) { + return normalized + } + + throw new Error(`Invalid LOG_LEVEL value: ${raw}`) +} + function requireValue(value: string | undefined, key: string): string { if (!value || value.trim().length === 0) { throw new Error(`${key} environment variable is required`) @@ -103,6 +123,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), + logLevel: parseLogLevel(env.LOG_LEVEL), telegramBotToken: requireValue(env.TELEGRAM_BOT_TOKEN, 'TELEGRAM_BOT_TOKEN'), telegramWebhookSecret: requireValue(env.TELEGRAM_WEBHOOK_SECRET, 'TELEGRAM_WEBHOOK_SECRET'), telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 334e184..82e2870 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -10,6 +10,7 @@ import { createDbFinanceRepository, createDbReminderDispatchRepository } from '@household/adapters-db' +import { configureLogger, getLogger } from '@household/observability' import { registerAnonymousFeedback } from './anonymous-feedback' import { createFinanceCommandsService } from './finance-commands' @@ -27,7 +28,13 @@ import { createMiniAppAuthHandler } from './miniapp-auth' import { createMiniAppDashboardHandler } from './miniapp-dashboard' const runtime = getBotRuntimeConfig() -const bot = createTelegramBot(runtime.telegramBotToken) +configureLogger({ + level: runtime.logLevel, + service: '@household/bot' +}) + +const logger = getLogger('runtime') +const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram')) const webhookHandler = webhookCallback(bot, 'std/http') const shutdownTasks: Array<() => Promise> = [] @@ -66,14 +73,21 @@ if (runtime.purchaseTopicIngestionEnabled) { purchaseTopicId: runtime.telegramPurchaseTopicId! }, purchaseRepositoryClient.repository, - llmFallback - ? { - llmFallback - } - : {} + { + ...(llmFallback + ? { + llmFallback + } + : {}), + logger: getLogger('purchase-ingestion') + } ) } else { - console.warn( + logger.warn( + { + event: 'runtime.feature_disabled', + feature: 'purchase-topic-ingestion' + }, 'Purchase topic ingestion is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_PURCHASE_TOPIC_ID to enable.' ) } @@ -83,7 +97,13 @@ if (runtime.financeCommandsEnabled) { financeCommands.register(bot) } else { - console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') + logger.warn( + { + event: 'runtime.feature_disabled', + feature: 'finance-commands' + }, + 'Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.' + ) } const reminderJobs = runtime.reminderJobsEnabled @@ -95,13 +115,18 @@ const reminderJobs = runtime.reminderJobsEnabled return createReminderJobsHandler({ householdId: runtime.householdId!, - reminderService + reminderService, + logger: getLogger('scheduler') }) })() : null if (!runtime.reminderJobsEnabled) { - console.warn( + logger.warn( + { + event: 'runtime.feature_disabled', + feature: 'reminder-jobs' + }, 'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' ) } @@ -111,10 +136,15 @@ if (anonymousFeedbackService) { bot, anonymousFeedbackService, householdChatId: runtime.telegramHouseholdChatId!, - feedbackTopicId: runtime.telegramFeedbackTopicId! + feedbackTopicId: runtime.telegramFeedbackTopicId!, + logger: getLogger('anonymous-feedback') }) } else { - console.warn( + logger.warn( + { + event: 'runtime.feature_disabled', + feature: 'anonymous-feedback' + }, 'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.' ) } @@ -127,14 +157,16 @@ const server = createBotWebhookServer({ ? createMiniAppAuthHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - repository: financeRepositoryClient.repository + repository: financeRepositoryClient.repository, + logger: getLogger('miniapp-auth') }) : undefined, miniAppDashboard: financeService ? createMiniAppDashboardHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - financeService + financeService, + logger: getLogger('miniapp-dashboard') }) : undefined, scheduler: @@ -162,11 +194,24 @@ if (import.meta.main) { fetch: server.fetch }) - console.log( - `@household/bot webhook server started on :${runtime.port} path=${runtime.telegramWebhookPath}` + logger.info( + { + event: 'runtime.started', + port: runtime.port, + webhookPath: runtime.telegramWebhookPath + }, + 'Bot webhook server started' ) process.on('SIGTERM', () => { + logger.info( + { + event: 'runtime.shutdown', + signal: 'SIGTERM' + }, + 'Bot shutdown requested' + ) + for (const close of shutdownTasks) { void close() } diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 8d99693..bbb1240 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -1,4 +1,5 @@ import type { FinanceMemberRecord, FinanceRepository } from '@household/ports' +import type { Logger } from '@household/observability' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' @@ -60,18 +61,19 @@ export async function readMiniAppInitData(request: Request): Promise 0 ? initData : null } -export function miniAppErrorResponse(error: unknown, origin?: string): Response { +export function miniAppErrorResponse(error: unknown, origin?: string, logger?: Logger): Response { const message = error instanceof Error ? error.message : 'Unknown mini app error' if (message === 'Invalid JSON body') { return miniAppJsonResponse({ ok: false, error: message }, 400, origin) } - console.error( - JSON.stringify({ + logger?.error( + { event: 'miniapp.request_failed', error: message - }) + }, + 'Mini app request failed' ) return miniAppJsonResponse({ ok: false, error: 'Internal Server Error' }, 500, origin) @@ -128,6 +130,7 @@ export function createMiniAppAuthHandler(options: { allowedOrigins: readonly string[] botToken: string repository: FinanceRepository + logger?: Logger }): { handler: (request: Request) => Promise } { @@ -190,7 +193,7 @@ export function createMiniAppAuthHandler(options: { origin ) } catch (error) { - return miniAppErrorResponse(error, origin) + return miniAppErrorResponse(error, origin, options.logger) } } } diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index bbcf8cb..a4cf38b 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -1,4 +1,5 @@ import type { FinanceCommandService } from '@household/application' +import type { Logger } from '@household/observability' import { allowedMiniAppOrigin, @@ -12,6 +13,7 @@ export function createMiniAppDashboardHandler(options: { allowedOrigins: readonly string[] botToken: string financeService: FinanceCommandService + logger?: Logger }): { handler: (request: Request) => Promise } { @@ -99,7 +101,7 @@ export function createMiniAppDashboardHandler(options: { origin ) } catch (error) { - return miniAppErrorResponse(error, origin) + return miniAppErrorResponse(error, origin, options.logger) } } } diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 37e0af6..7175f33 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -1,6 +1,7 @@ import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' import { and, eq } from 'drizzle-orm' import type { Bot, Context } from 'grammy' +import type { Logger } from '@household/observability' import { createDbClient, schema } from '@household/db' @@ -197,6 +198,7 @@ export function registerPurchaseTopicIngestion( repository: PurchaseMessageIngestionRepository, options: { llmFallback?: PurchaseParserLlmFallback + logger?: Logger } = {} ): void { bot.on('message:text', async (ctx, next) => { @@ -216,12 +218,30 @@ export function registerPurchaseTopicIngestion( const status = await repository.save(record, options.llmFallback) if (status === 'created') { - console.log( - `purchase topic message ingested chat=${record.chatId} thread=${record.threadId} message=${record.messageId}` + options.logger?.info( + { + event: 'purchase.ingested', + chatId: record.chatId, + threadId: record.threadId, + messageId: record.messageId, + updateId: record.updateId, + senderTelegramUserId: record.senderTelegramUserId + }, + 'Purchase topic message ingested' ) } } catch (error) { - console.error('Failed to ingest purchase topic message', error) + options.logger?.error( + { + event: 'purchase.ingest_failed', + chatId: record.chatId, + threadId: record.threadId, + messageId: record.messageId, + updateId: record.updateId, + error + }, + 'Failed to ingest purchase topic message' + ) } }) } diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index a05143e..37db97e 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -1,5 +1,6 @@ import type { ReminderJobService } from '@household/application' import { BillingPeriod } from '@household/domain' +import type { Logger } from '@household/observability' import { REMINDER_TYPES, type ReminderType } from '@household/ports' interface ReminderJobRequestBody { @@ -51,6 +52,7 @@ export function createReminderJobsHandler(options: { householdId: string reminderService: ReminderJobService forceDryRun?: boolean + logger?: Logger }): { handle: (request: Request, rawReminderType: string) => Promise } { @@ -83,7 +85,7 @@ export function createReminderJobsHandler(options: { dryRun } - console.log(JSON.stringify(logPayload)) + options.logger?.info(logPayload, 'Reminder job processed') return json({ ok: true, @@ -98,12 +100,13 @@ export function createReminderJobsHandler(options: { } catch (error) { const message = error instanceof Error ? error.message : 'Unknown reminder job error' - console.error( - JSON.stringify({ + options.logger?.error( + { event: 'scheduler.reminder.dispatch_failed', reminderType: rawReminderType, error: message - }) + }, + 'Reminder job failed' ) return json({ ok: false, error: message }, 400) diff --git a/bun.lock b/bun.lock index e6c53e5..a64273e 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@household/application": "workspace:*", "@household/db": "workspace:*", "@household/domain": "workspace:*", + "@household/observability": "workspace:*", "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", "google-auth-library": "^10.4.1", @@ -78,6 +79,9 @@ }, "packages/observability": { "name": "@household/observability", + "dependencies": { + "pino": "^9.9.0", + }, }, "packages/ports": { "name": "@household/ports", @@ -267,6 +271,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], @@ -415,6 +421,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="], "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], @@ -601,6 +609,8 @@ "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -615,10 +625,22 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], @@ -627,6 +649,8 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], @@ -645,12 +669,16 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -663,6 +691,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index 049e30a..295a4f7 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -1,24 +1,49 @@ import { createEnv } from '@t3-oss/env-core' import { z } from 'zod' +function parseOptionalCsv(value: string | undefined): readonly string[] | undefined { + const trimmed = value?.trim() + + if (!trimmed) { + return undefined + } + + return trimmed + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + const server = { NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), - APP_URL: z.string().url(), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), DATABASE_URL: z.string().url(), - SUPABASE_URL: z.string().url(), - SUPABASE_PUBLISHABLE_KEY: z.string().min(1), - SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + HOUSEHOLD_ID: z.string().uuid(), + SUPABASE_URL: z.string().url().optional(), + SUPABASE_PUBLISHABLE_KEY: z.string().min(1).optional(), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(), TELEGRAM_BOT_TOKEN: z.string().min(1), TELEGRAM_WEBHOOK_SECRET: z.string().min(1), - TELEGRAM_BOT_USERNAME: z.string().min(1), - OPENAI_API_KEY: z.string().min(1), + TELEGRAM_WEBHOOK_PATH: z.string().min(1).default('/webhook/telegram'), + TELEGRAM_HOUSEHOLD_CHAT_ID: z.string().min(1).optional(), + TELEGRAM_PURCHASE_TOPIC_ID: z.coerce.number().int().positive().optional(), + TELEGRAM_FEEDBACK_TOPIC_ID: z.coerce.number().int().positive().optional(), + MINI_APP_ALLOWED_ORIGINS: z + .string() + .optional() + .transform((value) => parseOptionalCsv(value)), + SCHEDULER_OIDC_ALLOWED_EMAILS: z + .string() + .optional() + .transform((value) => parseOptionalCsv(value)), + OPENAI_API_KEY: z.string().min(1).optional(), PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'), SENTRY_DSN: z.string().url().optional(), - GCP_PROJECT_ID: z.string().min(1), + GCP_PROJECT_ID: z.string().min(1).optional(), GCP_REGION: z.string().min(1).default('europe-west1'), CLOUD_RUN_SERVICE_BOT: z.string().min(1).default('household-bot'), - SCHEDULER_SHARED_SECRET: z.string().min(1) + SCHEDULER_SHARED_SECRET: z.string().min(1).optional() } export const env = createEnv({ diff --git a/packages/observability/package.json b/packages/observability/package.json index 7ae8e8f..41a9422 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -2,10 +2,16 @@ "name": "@household/observability", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "build": "bun build src/index.ts --outdir dist --target bun", "typecheck": "tsgo --project tsconfig.json --noEmit", "test": "bun test --pass-with-no-tests", "lint": "oxlint \"src\"" + }, + "dependencies": { + "pino": "^9.9.0" } } diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 25e3e5f..5c46978 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -1 +1,53 @@ -export const observabilityReady = true +import pino, { type Bindings, type Logger, type LoggerOptions } from 'pino' + +export type { Logger } + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +let rootLogger = pino({ + level: 'info', + timestamp: pino.stdTimeFunctions.isoTime, + base: null, + formatters: { + level(label) { + return { + level: label + } + } + } +}) + +export function configureLogger( + options: { + level?: LogLevel + service?: string + base?: Bindings + } = {} +): Logger { + const loggerOptions: LoggerOptions = { + level: options.level ?? 'info', + timestamp: pino.stdTimeFunctions.isoTime, + base: null, + formatters: { + level(label) { + return { + level: label + } + } + } + } + + rootLogger = pino(loggerOptions).child({ + service: options.service ?? 'household', + ...options.base + }) + + return rootLogger +} + +export function getLogger(name: string, bindings: Bindings = {}): Logger { + return rootLogger.child({ + logger: name, + ...bindings + }) +}