diff --git a/.env.example b/.env.example index 5f05b75..9c00a46 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index df871fc..b9200da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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/.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 `@/../` (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 `Relations`. No global `relations.ts`. 7. One router file per feature (`routers/.router.ts`). Only introduce `routers//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. diff --git a/bun.lock b/bun.lock index d49136c..7ae5741 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index be24b5c..c7f3507 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/migrate.ts b/src/cli/migrate.ts index 194a380..039df7c 100644 --- a/src/cli/migrate.ts +++ b/src/cli/migrate.ts @@ -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() } diff --git a/src/env.ts b/src/env.ts index dffe057..ee87105 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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: {}, diff --git a/src/server/api/interceptors.ts b/src/server/api/interceptors.ts index 8ba6f18..f88d7a6 100644 --- a/src/server/api/interceptors.ts +++ b/src/server/api/interceptors.ts @@ -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) => { diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 7635f26..1aafc6c 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -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, }) diff --git a/src/server/logger.ts b/src/server/logger.ts index 8b33125..ac18696 100644 --- a/src/server/logger.ts +++ b/src/server/logger.ts @@ -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 }