mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:14:03 +00:00
feat(observability): add structured pino logging
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void>> = []
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<string | nu
|
||||
return initData && initData.length > 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<Response>
|
||||
} {
|
||||
@@ -190,7 +193,7 @@ export function createMiniAppAuthHandler(options: {
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
return miniAppErrorResponse(error, origin)
|
||||
return miniAppErrorResponse(error, origin, options.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Response>
|
||||
} {
|
||||
@@ -99,7 +101,7 @@ export function createMiniAppDashboardHandler(options: {
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
return miniAppErrorResponse(error, origin)
|
||||
return miniAppErrorResponse(error, origin, options.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Response>
|
||||
} {
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user