From 454f02dfc1d8618dbfd63f2feb59929d74b03666 Mon Sep 17 00:00:00 2001 From: Carlos Date: Tue, 12 May 2026 10:36:51 +0200 Subject: [PATCH 01/11] feat: add observability-and-env skill with pino logger, Zod env schemas, and centralized Sentry bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/env/runtimeEnvSchema.ts with shared DeploymentEnv and LogLevel Zod schemas - Add src/env/webEnv.ts parsing process.env once; derive browser-safe WebPublicEnvSchema slice - Add src/utils/logger.ts with createModuleLogger pino factory (no process.env inside) - Add src/utils/serverLogger.ts with createServerLogger bound to webServerEnv - Add instrument.env.mjs with resolveSentryBootstrapEnv() for plain-JS bootstrap - Add instrument.shared.mjs with initSentry() receiving pre-resolved values (no process.env inside) - Simplify instrument.server.mjs to 9 lines using the two shared helpers - Add src/middleware/webEnv.ts injecting ctx.context.publicEnv for server functions - Update observability/index.ts to use webPublicEnv.SENTRY_DSN instead of process.env - Update middleware/auth.ts to use webServerEnv.AUTH_HEADER_NAME instead of process.env - Update build script to copy all instrument.*.mjs files to .output/server - Update AGENTS.md §9 and §13 to document implemented patterns; remove window.__ENV__ guidance - Update .env.example with ENV, LOG_LEVEL, and canonical SENTRY_DSN - Add pino and pino-pretty dependencies - Register observability-and-env skill in skills/registry.json; regenerate skill artifacts Co-authored-by: Cursor --- .agents/skills/observability-and-env/SKILL.md | 306 +++++++++++ .env.example | 12 +- AGENTS.md | 111 ++-- instrument.env.mjs | 25 + instrument.server.mjs | 24 +- instrument.shared.mjs | 40 ++ package.json | 4 +- pnpm-lock.yaml | 480 ++++++++---------- pnpm-workspace.yaml | 8 + skills/dist/observability-and-env.md | 329 ++++++++++++ skills/registry.json | 133 +++++ skills/src/observability-and-env.skill.yaml | 406 +++++++++++++++ src/env/runtimeEnvSchema.ts | 22 + src/env/webEnv.ts | 49 ++ src/middleware/auth.ts | 5 +- src/middleware/webEnv.ts | 10 + src/services/observability/index.ts | 6 +- src/utils/logger.ts | 62 +++ src/utils/serverLogger.ts | 13 + 19 files changed, 1716 insertions(+), 329 deletions(-) create mode 100644 .agents/skills/observability-and-env/SKILL.md create mode 100644 instrument.env.mjs create mode 100644 instrument.shared.mjs create mode 100644 pnpm-workspace.yaml create mode 100644 skills/dist/observability-and-env.md create mode 100644 skills/src/observability-and-env.skill.yaml create mode 100644 src/env/runtimeEnvSchema.ts create mode 100644 src/env/webEnv.ts create mode 100644 src/middleware/webEnv.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/serverLogger.ts diff --git a/.agents/skills/observability-and-env/SKILL.md b/.agents/skills/observability-and-env/SKILL.md new file mode 100644 index 0000000..93a770c --- /dev/null +++ b/.agents/skills/observability-and-env/SKILL.md @@ -0,0 +1,306 @@ +--- +name: observability-and-env +description: 'Use when adding structured logging (pino), centralized environment + validation (Zod), or Sentry initialization to a TanStack Start app. Teaches + the three-file bootstrap pattern (instrument.env.mjs → instrument.shared.mjs → + instrument.server.mjs), the src/env/ schema split (server vs public), and the + createModuleLogger / createServerLogger factory pattern that eliminates + scattered process.env access from application code. Project: TanStack + AI-Promptable Full-Stack Template. Triggers on "add logging", "set up pino", + "pino logger", "sentry init", "instrument server", "instrument.server.mjs", + "env schema", "environment validation", "centralize observability", + "createModuleLogger", "createServerLogger", "webEnv", "webServerEnv", + "LOG_LEVEL", "SENTRY_DSN".' +--- + +> This file is generated from `skills/src/*.skill.yaml`. Do not edit manually. +# Observability and Environment Setup + +**Purpose:** Establish a clean observability stack — validated env schemas, +structured pino logging, and centralized Sentry bootstrap — following the +patterns proven in production TanStack Start apps. Keeps `process.env` access +confined to two files; application code receives typed, validated values as +arguments. + +## Key invariants (do not violate) + +1. `process.env` is read **only** in `src/env/*.ts` (schema parse) and + `instrument.env.mjs` (Sentry bootstrap — before TS loads). +2. Logger options (`logLevel`, `environment`) are **passed as arguments** to + `createModuleLogger` — the factory never reads `process.env`. +3. Public env reaches the browser via the **root loader only** — no + `window.__ENV__` global. +4. The root pino logger is created **once** per process (lazy singleton); all + module loggers are `child()` instances of it. + +## File layout + +``` +src/env/ + runtimeEnvSchema.ts # DeploymentEnv, LogLevel, shared preprocessors + webEnv.ts # WebServerEnvSchema + WebPublicEnvSchema; parsed once + +src/utils/ + logger.ts # createModuleLogger(name, { environment?, logLevel? }) + serverLogger.ts # createServerLogger(name) — binds webServerEnv + +src/middleware/ + webEnv.ts # webEnvMiddleware: injects ctx.context.publicEnv + +instrument.env.mjs # resolveSentryBootstrapEnv() — plain JS, no Zod +instrument.shared.mjs # initSentry({ dsn, environment, serverName, release }) +instrument.server.mjs # 6-line entry: resolve + init +``` + +## src/env/runtimeEnvSchema.ts + +Shared Zod enums and preprocessors used by web env (and any future pipeline env). + +```typescript +import { z } from 'zod' + +/** Empty / whitespace-only strings → undefined (Node process.env values are strings). */ +export function envStringToUndefined(val: unknown): unknown { + if (val === undefined || val === null) return undefined + const s = String(val).trim() + return s === '' ? undefined : s +} + +export const DEPLOYMENT_ENV_VALUES = ['development', 'staging', 'production'] as const +export const DeploymentEnvSchema = z.enum(DEPLOYMENT_ENV_VALUES) +export type DeploymentEnv = z.infer + +export const LOG_LEVEL_VALUES = ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'] as const +export const LogLevelSchema = z.enum(LOG_LEVEL_VALUES) +export type LogLevel = z.infer + +export const OptionalDeploymentEnvSchema = z.preprocess(envStringToUndefined, DeploymentEnvSchema.optional()) +export const OptionalLogLevelSchema = z.preprocess(envStringToUndefined, LogLevelSchema.optional()) +export const OptionalTrimmedStringSchema = z.preprocess(envStringToUndefined, z.string().optional()) +``` + +## src/env/webEnv.ts + +Parsed once when first imported. `WebPublicEnvSchema` is the browser-safe +slice — only non-secret fields. `WebServerEnvSchema` adds secrets. + +```typescript +import { z } from 'zod' +import { + OptionalDeploymentEnvSchema, + OptionalLogLevelSchema, + OptionalTrimmedStringSchema, +} from './runtimeEnvSchema' + +export const WebPublicEnvSchema = z.object({ + ENV: OptionalDeploymentEnvSchema.describe('Deployment name: development, staging, or production.'), + LOG_LEVEL: OptionalLogLevelSchema.describe('Minimum pino log level.'), + SENTRY_DSN: OptionalTrimmedStringSchema.describe('Sentry DSN for both server and browser.'), +}) +export type WebPublicEnv = z.infer + +export const WebServerEnvSchema = WebPublicEnvSchema.extend({ + // Accepted for backwards compatibility; use SENTRY_DSN in new config. + VITE_SENTRY_DSN: OptionalTrimmedStringSchema, + // ... app-specific required/optional vars + AUTH_HEADER_NAME: z.string().optional(), +}) +export type WebServerEnv = z.infer + +export const webServerEnv: WebServerEnv = WebServerEnvSchema.parse(process.env) + +export const webPublicEnv: WebPublicEnv = WebPublicEnvSchema.parse({ + ...webServerEnv, + // Normalise VITE_ alias → canonical SENTRY_DSN + SENTRY_DSN: webServerEnv.VITE_SENTRY_DSN || webServerEnv.SENTRY_DSN, +}) +``` + +**Add required secrets** (API keys, DB URIs) to `WebServerEnvSchema` only — +they must never appear in `WebPublicEnvSchema`. + +## src/utils/logger.ts + +Pino factory. No `process.env` access — env values come from the caller. + +```typescript +import pino, { type Logger } from 'pino' +import type { DeploymentEnv, LogLevel } from '../env/runtimeEnvSchema' + +export type ModuleLoggerOptions = { + environment?: DeploymentEnv // validated by caller's env schema + logLevel?: LogLevel // validated by caller's env schema +} + +let rootLogger: Logger | null = null + +function getRootLogger(environment?: DeploymentEnv): Logger { + if (rootLogger) return rootLogger + // Safe for browser bundles: process.stdout may be undefined + const isNodeTty = typeof process !== 'undefined' && process.stdout != null && Boolean(process.stdout.isTTY) + const useTtyPretty = isNodeTty && environment !== 'production' + // Root at 'trace' so child level overrides are never filtered out + rootLogger = useTtyPretty + ? pino({ level: 'trace' }, pino.transport({ + target: 'pino-pretty', + options: { colorize: true, singleLine: true, translateTime: 'HH:MM:ss.l' }, + })) + : pino({ level: 'trace' }) + return rootLogger +} + +export function createModuleLogger(name: string, options: ModuleLoggerOptions): Logger { + const { environment } = options + const bindings = environment ? { name, environment } : { name } + return getRootLogger(environment).child(bindings, { level: options.logLevel ?? 'info' }) +} +``` + +## src/utils/serverLogger.ts + +Thin bound factory for server-side modules — eliminates repeated +`{ environment: webServerEnv.ENV, logLevel: webServerEnv.LOG_LEVEL }` boilerplate. + +```typescript +import { webServerEnv } from '../env/webEnv' +import { createModuleLogger } from './logger' + +/** Server-side logger factory pre-bound to webServerEnv options. */ +export const createServerLogger = (name: string) => + createModuleLogger(name, { environment: webServerEnv.ENV, logLevel: webServerEnv.LOG_LEVEL }) +``` + +Usage in any server module: +```typescript +import { createServerLogger } from '../utils/serverLogger' +const log = createServerLogger('myServerFn') +``` + +## instrument.env.mjs + +Plain JS — no Zod, no TS — so it loads from the `--import` hook before any +transpilation. `VALID_ENVS` mirrors `DEPLOYMENT_ENV_VALUES` from +`runtimeEnvSchema.ts`; keep them in sync. + +```javascript +// VALID_ENVS mirrors DEPLOYMENT_ENV_VALUES in src/env/runtimeEnvSchema.ts +const VALID_ENVS = ['development', 'staging', 'production'] + +export function resolveSentryBootstrapEnv() { + const rawEnv = process.env.ENV?.trim() + return { + dsn: process.env.VITE_SENTRY_DSN || process.env.SENTRY_DSN, + environment: VALID_ENVS.includes(rawEnv) ? rawEnv : 'development', + } +} +``` + +## instrument.shared.mjs + +Receives all values pre-resolved — no `process.env` reads inside. + +```javascript +import * as Sentry from '@sentry/tanstackstart-react' + +/** + * @typedef {object} InitSentryOptions + * @property {string} serverName - Human-readable server name. + * @property {string|undefined} dsn - Sentry DSN. No-op when falsy. + * @property {'development'|'staging'|'production'} environment + * @property {string} [release] - Optional release identifier. + */ + +/** Initialize Sentry. No-op when no DSN is set. */ +export function initSentry({ serverName, dsn, environment, release }) { + if (!dsn) return + Sentry.init({ + dsn, + environment, + serverName, + ...(release ? { release } : {}), + sendDefaultPii: true, + tracesSampleRate: environment === 'production' ? 0.1 : 1.0, + }) +} +``` + +## instrument.server.mjs (simplified — 6 lines) + +```javascript +import { resolveSentryBootstrapEnv } from './instrument.env.mjs' +import { initSentry } from './instrument.shared.mjs' +import pkg from './package.json' with { type: 'json' } + +const { dsn, environment } = resolveSentryBootstrapEnv() +initSentry({ serverName: 'my-app', release: pkg.version, dsn, environment }) +``` + +Update the `build` script to copy all instrument files: +```json +"build": "vite build && cp instrument.*.mjs .output/server" +``` + +## webEnvMiddleware + +Injects `ctx.context.publicEnv` for server functions; the root loader exposes +it so React components can read `ENV`/`LOG_LEVEL`/`SENTRY_DSN` without a +`window.__ENV__` global. + +```typescript +// src/middleware/webEnv.ts +import { createMiddleware } from '@tanstack/react-start' +import { webPublicEnv } from '../env/webEnv' + +export const webEnvMiddleware = createMiddleware().server(({ next }) => + next({ context: { publicEnv: webPublicEnv } }) +) +``` + +Register in `src/start.ts`: +```typescript +import { webEnvMiddleware } from './middleware/webEnv' +// add to requestMiddleware array +``` + +In the root loader, expose it to the client: +```typescript +loader: async () => { + // ...existing loader data... + return { publicEnv: webPublicEnv } +} +``` + +## Updating call sites + +**observability/index.ts** — replace `process.env.VITE_SENTRY_DSN` with the +validated value from `webPublicEnv`: + +```typescript +import { webPublicEnv } from '../../env/webEnv' + +export function getObservability(): ObservabilityService { + if (!instance) { + instance = webPublicEnv.SENTRY_DSN + ? new SentryObservability() + : new NoopObservability() + } + return instance +} +``` + +**middleware/auth.ts** — replace `process.env.AUTH_HEADER_NAME` with +`webServerEnv`: + +```typescript +import { webServerEnv } from '../env/webEnv' +const AUTH_HEADER_NAME = webServerEnv.AUTH_HEADER_NAME ?? 'Authorization' +``` + +## Checklist + +- [ ] `process.env` appears only in `src/env/*.ts` and `instrument.env.mjs` +- [ ] `createModuleLogger` / `createServerLogger` never call `process.env` +- [ ] `instrument.server.mjs` uses `resolveSentryBootstrapEnv()` + `initSentry()` +- [ ] `build` script copies `instrument.*.mjs` (not just `instrument.server.mjs`) +- [ ] Public env exposed through root loader (not `window.__ENV__`) +- [ ] `SENTRY_DSN` / `LOG_LEVEL` / `ENV` documented in `.env.example` diff --git a/.env.example b/.env.example index c66f751..de7e332 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,19 @@ # AZURE_OPENAI_DEPLOYMENT=gpt-4o # ============================================================================= -# Observability (Sentry) +# Observability (Sentry + pino) # ============================================================================= # Sentry DSN. Observability is disabled (no-op) when omitted. -# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 +# SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 +# VITE_SENTRY_DSN is also accepted as a backwards-compatible alias for SENTRY_DSN. +# +# Deployment name — controls trace sampling and log routing. +# Allowed values: development | staging | production # ENV=development +# +# Minimum pino log level. +# Allowed values: fatal | error | warn | info | debug | trace | silent +# LOG_LEVEL=info # ============================================================================= # Authentication diff --git a/AGENTS.md b/AGENTS.md index bd2f099..5315034 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -431,13 +431,40 @@ The chat endpoint is a TanStack Start file-based route at `/api/chat` with two s ## 9. Observability +### ObservabilityService interface + - **Interface**: `src/services/observability/types.ts` defines the `ObservabilityService` interface. -- **Implementations**: Sentry (`sentry.ts`) and no-op (`noop.ts`). Factory in `index.ts`. -- **Server Init**: `instrument.server.mjs` conditionally initializes the server-side SDK. +- **Implementations**: Sentry (`sentry.ts`) and no-op (`noop.ts`). Factory in `index.ts` selects based on `webPublicEnv.SENTRY_DSN`. - **Client Init**: `src/router.tsx` conditionally initializes client-side tracing. - **Usage**: Wrap server function internals with `getObservability().startSpan('name', fn)`. - **To swap providers**: Implement `ObservabilityService`, update the factory, and replace `instrument.server.mjs`. +### Sentry bootstrap (three-file pattern) + +Server-side Sentry is initialized via the `--import` hook before any TypeScript loads: + +| File | Responsibility | +|------|---------------| +| `instrument.env.mjs` | Plain JS — `resolveSentryBootstrapEnv()` reads `process.env.ENV` + `SENTRY_DSN` once | +| `instrument.shared.mjs` | `initSentry({ serverName, dsn, environment, release })` — no `process.env` inside | +| `instrument.server.mjs` | 9-line entry: resolve + init | + +**Key invariant**: `instrument.shared.mjs` never reads `process.env`. All values are pre-resolved by the caller. + +### pino structured logging + +Application modules use the pino factory from `src/utils/logger.ts`: + +```typescript +import { createServerLogger } from '../utils/serverLogger' +const log = createServerLogger('myModule') // binds webServerEnv.ENV + LOG_LEVEL +``` + +- `createModuleLogger(name, { environment?, logLevel? })` — core factory; no `process.env` access inside. +- `createServerLogger(name)` — bound factory for server modules; closes over `webServerEnv.ENV` and `webServerEnv.LOG_LEVEL`. +- Root pino instance is lazily created once per process; all module loggers are `child()` instances. +- TTY pretty-printing is auto-enabled for interactive Node terminals outside of production. + ### App Version The app follows [semver](https://semver.org/). The version in `package.json` is extracted at **build time** via Vite's `define` option and exposed as the global constant `__APP_VERSION__`. This constant is injected into every observability tool so that error reports, log lines, and traces are tagged with the exact deployed version. @@ -565,53 +592,61 @@ This project uses [Biome](https://biomejs.dev/) as the default linter and format ## 13. Public Runtime Config -Some config values differ per deployment (Sentry DSN, environment name, feature flags) but are **not secrets** and the client needs them immediately. Do not bake them into the Vite bundle via `import.meta.env` — that ties the built artifact to one environment. Instead, expose them through a GET server function and inline the result into the HTML document as `window.__ENV__` so the client can read them synchronously before any module runs. +Config values that differ per deployment (Sentry DSN, environment name, feature flags) and are safe for the browser **must not** be baked into the Vite bundle via `import.meta.env` — that ties the built artifact to one environment. They also **must not** be injected via `window.__ENV__` — that is insecure and fragile. + +Instead, expose them through the validated `WebPublicEnvSchema` slice and deliver them to the browser exclusively through the **root loader**. + +### Environment schemas (`src/env/`) -### Server function +`process.env` is read **only once**, at module import time: + +| File | Purpose | +|------|---------| +| `src/env/runtimeEnvSchema.ts` | Shared `DeploymentEnvSchema`, `LogLevelSchema`, and optional preprocessors | +| `src/env/webEnv.ts` | `WebServerEnvSchema` (full, including secrets) + `WebPublicEnvSchema` (browser-safe slice); singletons `webServerEnv` + `webPublicEnv` | + +**Invariant**: after `src/env/webEnv.ts` is parsed, all downstream code receives typed values from the singletons — never raw `process.env` strings. + +### Public env via the root loader + +Expose `webPublicEnv` through the root loader so React components can read `ENV`, `LOG_LEVEL`, and `SENTRY_DSN`: ```tsx -// src/services/api/serverFns.ts -export const getPublicEnv = createServerFn({ method: 'GET' }).handler(async () => { - return { - sentryDsn: process.env.VITE_SENTRY_DSN ?? null, - environment: process.env.ENV ?? 'development', - featureFlags: { - /* flags that are safe to expose */ - }, - } +// src/routes/__root.tsx +import { webPublicEnv } from '../env/webEnv' + +export const Route = createRootRoute({ + loader: async () => ({ + publicEnv: webPublicEnv, + // ...other root loader data + }), + // ... }) ``` -### Inline into the document +React components and client-side SDK init read `publicEnv` from `useLoaderData({ from: '__root__' })`. -Call `getPublicEnv()` in the root loader and inject the result via a `