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
+5
View File
@@ -1 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
# Optional logging knobs (defaults are usually fine):
# LOG_LEVEL=info # trace|debug|info|warning|error|fatal
# LOG_FORMAT=pretty # pretty|json — defaults to TTY ? pretty : json
# LOG_DB=false # true to log every Drizzle SQL query
+23 -4
View File
@@ -8,6 +8,7 @@ Compact, repo-specific notes for AI agents. Generic language/framework knowledge
- TanStack Start (React 19 SSR, file-routed) + Vite 8 + Nitro (nightly, preset `bun`). Vite dev port is **strict 3000**.
- PostgreSQL + **Drizzle ORM `0.45.2` (0.x, NOT 1.0 beta)** — see "Drizzle" section, this matters a lot.
- ORPC (contract-first), TanStack Query v5, Tailwind v4.
- **Logging via [LogTape](https://logtape.org/)** (zero-dep, runtime-agnostic) — see "Logging" section. `console.*` is forbidden in business code.
## Scripts
@@ -87,7 +88,7 @@ Contract → Router → Handler → Client, all type-safe from a single contract
```
Barrel-aggregated in `contracts/index.ts` as `export const contract = { todo }`.
- Routers (`src/server/api/routers/*.router.ts`) import `db` directly from `@/server/db` in their handlers. There is **no `middlewares/` directory** by default — `db` doesn't need one (module-level const). When you actually need per-request context (auth, tenant, rate-limit), create `src/server/api/middlewares/<name>.middleware.ts` with `os.middleware(...)` and extend `BaseContext` in `context.ts`.
- **Interceptors are attached at the handler level, not in `server.ts` and not on `os`.** Both `src/routes/api/rpc.$.ts` (`RPCHandler`) and `src/routes/api/$.ts` (`OpenAPIHandler`) register `[onError(logError)]` (server) and `[onError(handleValidationError)]` (client). The validation interceptor rewrites `BAD_REQUEST + ValidationError` into `INPUT_VALIDATION_FAILED` (422) and output validation errors into `OUTPUT_VALIDATION_FAILED`. `logError` calls `logger.error` from `@/server/logger` — never `console.*` directly.
- **Interceptors are attached at the handler level, not in `server.ts` and not on `os`.** Both `src/routes/api/rpc.$.ts` (`RPCHandler`) and `src/routes/api/$.ts` (`OpenAPIHandler`) register `[onError(logError)]` (server) and `[onError(handleValidationError)]` (client). The validation interceptor rewrites `BAD_REQUEST + ValidationError` into `INPUT_VALIDATION_FAILED` (422) and output validation errors into `OUTPUT_VALIDATION_FAILED`. `logError` resolves a `getLogger(['api'])` LogTape category — never `console.*` directly. See "Logging" section.
- OpenAPI/Scalar: docs at `/api/docs`, spec at `/api/spec.json` (handler prefix `/api`, plugin paths `/docs` and `/spec.json`).
- **SSR isomorphism** (`src/client/orpc.ts`): `createIsomorphicFn().server(createRouterClient(...)).client(new RPCLink(...))`. Server branch reads `getRequestHeaders()` for context; client branch POSTs to `${origin}/api/rpc`.
- **Mutation invalidation is colocated at the call site** via `mutationOptions({ onSuccess })`, not in `src/client/orpc.ts`. `orpc` in `src/client/orpc.ts` is a plain `createTanstackQueryUtils(client)` — no `experimental_defaults`. Per-feature query helpers live in `src/client/queries/<feature>.ts` (e.g. `useInvalidateTodos`); routes/components compose those hooks rather than holding query keys inline. See `src/client/queries/todo.ts` + `src/routes/index.tsx` for the canonical shape.
@@ -120,7 +121,25 @@ Path alias: `@/* → src/*`. For files outside `src/` use `@/../<file>` (example
## Env
`src/env.ts` via `@t3-oss/env-core`. Server: `DATABASE_URL` (required, `z.url()`). `client: {}` is empty by default — any client-side env must be `VITE_`-prefixed. Never commit `.env`.
`src/env.ts` via `@t3-oss/env-core`. Server: `DATABASE_URL` (required, `z.url()`), `LOG_LEVEL` (`trace|debug|info|warning|error|fatal`, default `info`), `LOG_FORMAT` (`pretty|json`, default = TTY ? `pretty` : `json`), `LOG_DB` (`stringbool`, default `false` — flips on Drizzle SQL query logging). `client: {}` is empty by default — any client-side env must be `VITE_`-prefixed. Never commit `.env`.
## Logging
All server-side logging goes through `src/server/logger.ts`, a thin wrapper over [LogTape](https://logtape.org/). The module configures LogTape on import (via `configureSync`, no top-level await — works under `--bytecode`) and re-exports `getLogger`.
```ts
import { getLogger } from '@/server/logger'
const logger = getLogger(['feature', 'subsystem'])
logger.info('Created todo {id}', { id })
logger.error('DB write failed: {err}', { err })
```
- Categories are hierarchical arrays — they show up as dot-paths in JSON output (`"logger":"feature.subsystem"`) and let you filter by prefix when shipping logs.
- The `{name}` placeholders in the message string are filled from the second-arg properties object. Don't string-concatenate or template-literal — that defeats structured logging.
- Format is `pretty` (icons + ANSI) on TTY, `json` (one-line JSON) when piped — perfect for Loki/Datadog/CloudWatch ingestion. Override with `LOG_FORMAT`.
- Drizzle SQL queries are logged at `debug` under category `['db']` when `LOG_DB=true`, via `@logtape/drizzle-orm`'s `DrizzleLogger` adapter (constructed in `src/server/db/index.ts`).
- `src/server/api/interceptors.ts` calls `getLogger(['api']).error(...)` from `logError`. CLI subcommands lazy-import the logger inside `run()` — they are still required to be side-effect-free at module top (citty eager-loads for `--help`).
- Bun-specific: `process.env.NODE_ENV` is **inlined at build time** by `bun build --minify` — do NOT branch on it for logger config (use `process.stdout.isTTY` or `LOG_FORMAT` instead). pino is unusable here because its worker-thread transports crash inside the `/$bunfs/` virtual filesystem of compiled binaries; LogTape has zero workers and zero dynamic require, so it ships cleanly into the single binary.
## Docker / deploy
@@ -148,7 +167,7 @@ src/
│ ├── $.ts # OpenAPI + Scalar; interceptors registered here
│ └── rpc.$.ts # RPC; interceptors registered here
├── server/
│ ├── logger.ts # the only log entrypoint — wrap before swapping to pino/otel
│ ├── logger.ts # LogTape `configureSync` + `getLogger` re-export — the only log entrypoint
│ ├── api/
│ │ ├── server.ts # the ONLY place to build `os`
│ │ ├── context.ts # BaseContext (add per-request fields when you add middlewares)
@@ -203,6 +222,6 @@ These keep the starter from setting bad precedents as it grows. Append, don't re
5. Interceptors (`src/server/api/interceptors.ts`) do cross-cutting error logging, transport normalization, and validation rewrites. They do NOT read business data.
6. One file per Drizzle table. Relations live in the same file and are exported as `<entity>Relations`. No global `relations.ts`.
7. One router file per feature (`routers/<feature>.router.ts`). Only introduce `routers/<domain>/index.ts` when a domain grows past ~5 router files or needs shared domain helpers.
8. All server-side logging goes through `src/server/logger.ts`. Do not call `console.error/info/warn` directly from business code.
8. All server-side logging goes through `getLogger([...])` from `@/server/logger`. Use a hierarchical category (`['api']`, `['db']`, `['cli', 'migrate']`, etc.) — these become dot-paths in JSON output and let you filter by prefix. Use the `{name}` placeholder + properties form, not string interpolation. `console.*` is forbidden in business code.
9. CLI subcommand modules keep top-level imports to `citty` + Node built-ins. Env, db, and server code are `await import(...)`-ed inside `run()` (see `bin.ts` comment for why).
10. Every new business feature ships with at least one `bun test` covering a contract schema, a pure helper, or a router behavior.
+41 -32
View File
@@ -5,40 +5,43 @@
"": {
"name": "fullstack-starter",
"dependencies": {
"@orpc/client": "latest",
"@orpc/contract": "latest",
"@orpc/openapi": "latest",
"@orpc/server": "latest",
"@orpc/tanstack-query": "latest",
"@orpc/zod": "latest",
"@t3-oss/env-core": "latest",
"@tanstack/react-query": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"citty": "latest",
"drizzle-orm": "latest",
"drizzle-zod": "latest",
"postgres": "latest",
"react": "latest",
"react-dom": "latest",
"uuid": "latest",
"zod": "latest",
"@logtape/drizzle-orm": "^2.0.5",
"@logtape/logtape": "^2.0.5",
"@logtape/pretty": "^2.0.5",
"@orpc/client": "^1.14.0",
"@orpc/contract": "^1.14.0",
"@orpc/openapi": "^1.14.0",
"@orpc/server": "^1.14.0",
"@orpc/tanstack-query": "^1.14.0",
"@orpc/zod": "^1.14.0",
"@t3-oss/env-core": "^0.13.11",
"@tanstack/react-query": "^5.100.1",
"@tanstack/react-router": "^1.168.24",
"@tanstack/react-router-ssr-query": "^1.166.11",
"@tanstack/react-start": "^1.167.48",
"citty": "^0.2.2",
"drizzle-orm": "0.45.2",
"drizzle-zod": "^0.8.3",
"postgres": "^3.4.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"uuid": "^14.0.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@biomejs/biome": "latest",
"@tailwindcss/vite": "latest",
"@tanstack/devtools-vite": "latest",
"@tanstack/react-devtools": "latest",
"@tanstack/react-query-devtools": "latest",
"@tanstack/react-router-devtools": "latest",
"@types/bun": "latest",
"@vitejs/plugin-react": "latest",
"drizzle-kit": "latest",
"nitro": "npm:nitro-nightly@latest",
"tailwindcss": "latest",
"typescript": "latest",
"vite": "latest",
"@biomejs/biome": "^2.4.13",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/devtools-vite": "^0.6.0",
"@tanstack/react-devtools": "^0.10.2",
"@tanstack/react-query-devtools": "^5.100.1",
"@tanstack/react-router-devtools": "^1.166.13",
"@types/bun": "^1.3.13",
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "0.31.10",
"nitro": "npm:nitro-nightly@3.0.1-20260424-182106-f8cf6ccc",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.10",
},
},
},
@@ -173,6 +176,12 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@logtape/drizzle-orm": ["@logtape/drizzle-orm@2.0.5", "", { "peerDependencies": { "@logtape/logtape": "^2.0.5" } }, "sha512-woM4x9J7B4XzoQTY1bzR6uJVlqezn6oY6flV+rDD6lsuwP2llNVvptgCOGjRutDye+6WZldSUIGe7UFK/faBzA=="],
"@logtape/logtape": ["@logtape/logtape@2.0.5", "", {}, "sha512-UizDkh20ZPJVOddRxG1F77WhHdlNl/sbQgoO8T534R7XvUBMAJ9En9f35u+meW2tRsNLvjz6R87Zanwf53tspQ=="],
"@logtape/pretty": ["@logtape/pretty@2.0.5", "", { "peerDependencies": { "@logtape/logtape": "^2.0.5" } }, "sha512-jU5pYL0CW0tFmxBS5umMF5VEMq1vXLvkqKrj7KRHnSlb5SrBOSCYl0w4q7FmPPFVADmgTmzVVr6IcIWwK/2Nig=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
+3
View File
@@ -31,6 +31,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@logtape/drizzle-orm": "^2.0.5",
"@logtape/logtape": "^2.0.5",
"@logtape/pretty": "^2.0.5",
"@orpc/client": "^1.14.0",
"@orpc/contract": "^1.14.0",
"@orpc/openapi": "^1.14.0",
+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 }