feat(logging): 引入 LogTape 替换 console.* 为结构化日志

为什么选 LogTape(2026 实测):
- pino 在 bun build --compile 编译产物里因 worker_threads + 动态 require 在
  /\$bunfs/ 虚拟文件系统中崩溃,与单二进制部署核心目标冲突;
- LogTape 零依赖(5.3KB)、零 worker、纯 ESM、原生 Bun 导出条件,runtime
  agnostic,配合 configureSync 完美兼容 --bytecode 模式(无裸 top-level await);
- 一等公民集成:@logtape/drizzle-orm(SQL 查询日志)、@logtape/otel(后续
  OpenTelemetry sink 留扩展点)。

变更:
- src/server/logger.ts: configureSync 引导 + getLogger 重导出。format 默认
  process.stdout.isTTY ? pretty : json,可经 LOG_FORMAT 显式覆盖(绕开 Bun
  bundler 把 process.env.NODE_ENV 在 --minify 时 inline 成字面量的特殊处理)。
- src/server/api/interceptors.ts: logError 改用 getLogger(['api']).error(...) +
  结构化 properties,弃 logger.error 顶层 API。
- src/cli/migrate.ts: 所有 console.log 改走 getLogger(['cli','migrate']),logger
  在 run() 内 lazy-import 以保持 citty subcommand 模块体 side-effect-free。
- src/server/db/index.ts: env.LOG_DB=true 时挂 DrizzleLogger 适配器,SQL 查询
  按类别 ['db'] 在 debug 级输出(含 query/params/formattedQuery 三字段)。

新增 env 旋钮(t3-oss 校验):
- LOG_LEVEL: trace|debug|info|warning|error|fatal,默认 info
- LOG_FORMAT: pretty|json,默认 TTY 自动选
- LOG_DB: stringbool,默认 false

端到端验证(compose + Postgres 18-alpine):
- TTY 终端:pretty 输出含  图标 + ANSI 彩色 + 类别·路径 ✓
- 管道/Docker:JSON Lines 一行一条,含 @timestamp/level/logger/properties ✓
- LOG_FORMAT=pretty 强制覆盖 ✓
- ./server migrate 应用 migration 并经 logger 输出 ✓
- ./server serve + RPC round-trip:interceptor logError 与 drizzle SQL 日志
  在生产 JSON 模式下结构化输出 ✓
- fix / typecheck / test 3/3 / build / compile 117M 二进制全绿
This commit is contained in:
2026-04-25 16:04:31 +08:00
parent d206a3315f
commit cc3a5dc5ad
9 changed files with 109 additions and 48 deletions
+8 -5
View File
@@ -6,16 +6,19 @@ export default defineCommand({
description: 'Apply pending database migrations',
},
async run() {
const [{ env }, { drizzle }, { sql }, { embeddedMigrations }, { createHash }] = await Promise.all([
const [{ env }, { drizzle }, { sql }, { embeddedMigrations }, { createHash }, { getLogger }] = await Promise.all([
import('@/env'),
import('drizzle-orm/postgres-js'),
import('drizzle-orm'),
import('@/server/db/migrations.gen'),
import('node:crypto'),
import('@/server/logger'),
])
const logger = getLogger(['cli', 'migrate'])
if (embeddedMigrations.length === 0) {
console.log('No migrations bundled into this binary.')
logger.info('No migrations bundled into this binary.')
return
}
@@ -51,11 +54,11 @@ export default defineCommand({
const appliedWhens = new Set(applied.map((r) => Number(r.created_at)))
const pending = embeddedMigrations.filter((m) => !appliedWhens.has(m.when))
if (pending.length === 0) {
console.log('Database is up to date.')
logger.info('Database is up to date.')
return
}
console.log(`Applying ${pending.length} migration(s)...`)
logger.info('Applying {count} migration(s)...', { count: pending.length })
await db.transaction(async (tx) => {
for (const m of pending) {
for (const rawStmt of m.sql.split('--> statement-breakpoint')) {
@@ -68,7 +71,7 @@ export default defineCommand({
)
}
})
console.log('Migrations applied.')
logger.info('Migrations applied.')
} finally {
await db.$client.end()
}
+3
View File
@@ -4,6 +4,9 @@ import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.url({ protocol: /^postgres(ql)?$/ }),
LOG_DB: z.stringbool().default(false),
LOG_FORMAT: z.enum(['pretty', 'json']).optional(),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']).default('info'),
},
clientPrefix: 'VITE_',
client: {},
+4 -2
View File
@@ -1,9 +1,11 @@
import { ORPCError, ValidationError } from '@orpc/server'
import { z } from 'zod'
import { logger } from '@/server/logger'
import { getLogger } from '@/server/logger'
const logger = getLogger(['api'])
export const logError = (error: unknown) => {
logger.error(error)
logger.error('Unhandled error in ORPC handler: {error}', { error })
}
export const handleValidationError = (error: unknown) => {
+3
View File
@@ -1,8 +1,11 @@
import { DrizzleLogger } from '@logtape/drizzle-orm'
import { drizzle } from 'drizzle-orm/postgres-js'
import { env } from '@/env'
import * as schema from '@/server/db/schema'
import { getLogger } from '@/server/logger'
export const db = drizzle({
connection: env.DATABASE_URL,
schema,
logger: env.LOG_DB ? new DrizzleLogger(getLogger(['db'])) : false,
})
+19 -5
View File
@@ -1,5 +1,19 @@
export const logger = {
error: (error: unknown) => console.error(error),
warn: (...args: unknown[]) => console.warn(...args),
info: (...args: unknown[]) => console.info(...args),
}
import { configureSync, getConsoleSink, getJsonLinesFormatter, getLogger, type LogLevel } from '@logtape/logtape'
import { prettyFormatter } from '@logtape/pretty'
import { env } from '@/env'
const format = env.LOG_FORMAT ?? (process.stdout.isTTY ? 'pretty' : 'json')
configureSync({
reset: true,
sinks: {
console: getConsoleSink({ formatter: format === 'pretty' ? prettyFormatter : getJsonLinesFormatter() }),
},
loggers: [
{ category: [], lowestLevel: env.LOG_LEVEL, sinks: ['console'] },
{ category: ['logtape', 'meta'], lowestLevel: 'warning', sinks: ['console'] },
],
})
export type { LogLevel }
export { getLogger }