Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 317 additions & 0 deletions .agents/skills/observability-and-env/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
---
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.mts → instrument.shared.mts →
instrument.server.mts; emitted as .mjs for production), 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.mts", "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 in a
single `BootstrapEnvSchema.parse(process.env)` call inside
`instrument.env.mts` (Sentry bootstrap — runs before the app entry).
2. Logger options (`logLevel`, `environment`) are **passed as arguments** to
`createModuleLogger` — the factory never reads `process.env`.
3. **Browser public env** — no `window.__ENV__`; use a route `loader` that calls a GET server function returning `webPublicEnv`. Do not import `webEnv` from client-shared route files.
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.shared.mts # shared DeploymentEnvSchema for bootstrap + TS callers
instrument.env.mts # resolveSentryBootstrapEnv()
instrument.shared.mts # initSentry({ dsn, environment, serverName, release })
instrument.server.mts # bootstrap entry: resolve + init (dev); emitted .mjs in .output/server for prod
tsconfig.instrument.json
```

## src/env/runtimeEnvSchema.ts

Shared Zod enums and preprocessors used by web env (and any future pipeline env).

```typescript
import { z } from 'zod'
import { DEPLOYMENT_ENV_VALUES, type DeploymentEnv, DeploymentEnvSchema } from '../../instrument.env.shared.mjs'

/** 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 type { DeploymentEnv }
export { DEPLOYMENT_ENV_VALUES, DeploymentEnvSchema }

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<typeof LogLevelSchema>

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<typeof WebPublicEnvSchema>

export const WebServerEnvSchema = WebPublicEnvSchema.extend({
// ... app-specific required/optional vars
AUTH_HEADER_NAME: z.string().optional(),
})
export type WebServerEnv = z.infer<typeof WebServerEnvSchema>

export const webServerEnv: WebServerEnv = WebServerEnvSchema.parse(process.env)

export const webPublicEnv: WebPublicEnv = {
ENV: webServerEnv.ENV,
LOG_LEVEL: webServerEnv.LOG_LEVEL,
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
// Server-only — not safe for client bundles.
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
return getRootLogger(environment).child({ name, environment }, { 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 ?? 'development', logLevel: webServerEnv.LOG_LEVEL })
```

Usage in any server module:
```typescript
import { createServerLogger } from '../utils/serverLogger'
const log = createServerLogger('myServerFn')
```

## instrument.env.mts

TypeScript bootstrap module compiled to ESM for production. In dev, preload
`tsx` and import `instrument.server.mts` directly. Use `.mjs` extensions on
**relative imports between instrument files** so `moduleResolution: NodeNext`
maps to the emitted `.mjs` output. Bootstrap env stays validated in one place;
deployment enum is imported from `./instrument.env.shared.mjs` (source is
`.mts`). Keep strict: invalid `NODE_ENV` values fail at
`BootstrapEnvSchema.parse(process.env)`, and Sentry uses only `SENTRY_DSN`.

```typescript
import { z } from 'zod'
import { DeploymentEnvSchema } from './instrument.env.shared.mjs'

const BootstrapEnvSchema = z.object({
NODE_ENV: DeploymentEnvSchema.optional(),
SENTRY_DSN: z.string().optional(),
})

export function resolveSentryBootstrapEnv() {
const env = BootstrapEnvSchema.parse(process.env)
return {
dsn: env.SENTRY_DSN,
environment: env.NODE_ENV ?? 'development',
}
}
```

## instrument.shared.mts

Receives all values pre-resolved — no `process.env` reads inside.

```typescript
import * as Sentry from '@sentry/tanstackstart-react'

export type InitSentryOptions = {
serverName: string
dsn: string | undefined
environment: 'development' | 'staging' | 'production'
release?: string
}

export function initSentry({ serverName, dsn, environment, release }: InitSentryOptions): void {
if (!dsn) return
Sentry.init({
dsn,
environment,
serverName,
...(release ? { release } : {}),
sendDefaultPii: true,
tracesSampleRate: environment === 'production' ? 0.1 : 1.0,
})
}
```

## instrument.server.mts (bootstrap entry)

```typescript
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 })
```

**Dev** — preload `tsx` then this file, e.g. `NODE_OPTIONS='--import tsx --import ./instrument.server.mts'`.

**Production** — `tsc -p tsconfig.instrument.json` emits `.mjs` beside the Vite server bundle; copy `package.json` into `.output/server` so the import above resolves.

Update the `build` script:
```json
"build": "vite build && tsc -p tsconfig.instrument.json && cp package.json .output/server/package.json"
```

## webEnvMiddleware

Injects `ctx.context.publicEnv` for server functions. For React, fetch public
env in a route loader via a GET server function (do not import `webEnv` in
client bundles).

```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
```

Example server fn + loader pattern when a client component needs the slice:
```typescript
// serverFns.ts
export const getWebPublicEnv = createServerFn({ method: 'GET' }).handler(async () => webPublicEnv)

// route loader
loader: async () => ({ publicEnv: await getWebPublicEnv() })
```

## Updating call sites

**observability/index.ts** — replace `process.env.SENTRY_DSN` with the
validated value from `webPublicEnv`:

```typescript
import { webPublicEnv } from '../../env/webEnv'

export function getObservability(options: GetObservabilityOptions): ObservabilityService {
const dsn = options.publicEnv?.SENTRY_DSN ?? webPublicEnv.SENTRY_DSN
if (!instance || instanceKey !== dsn) {
instance = dsn ? new SentryObservability() : new NoopObservability()
instanceKey = dsn
}
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 one bootstrap schema parse in `instrument.env.mts`
- [ ] `createModuleLogger` / `createServerLogger` never call `process.env`
- [ ] `instrument.server.mts` uses `resolveSentryBootstrapEnv()` + `initSentry()`, and `pnpm build` emits `.output/server/instrument.*.mjs`
- [ ] `package.json` is copied next to the emitted instrument bundle so version import works
- [ ] Public env for the browser uses a GET server fn from route loaders (not `window.__ENV__`)
- [ ] `SENTRY_DSN` / `LOG_LEVEL` / `ENV` documented in `.env.example`
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ description: 'Use when scaffolding a new TanStack Start project, adding domain
**Purpose:** Capture the **interface-first, schema-layered, AI-promptable** contract for TanStack Start apps from this template. Day-to-day conventions (UI kit, chat wiring, logging, tests) live in the repo’s **AGENTS.md** — use this skill for **architecture**, AGENTS.md for **operations**.

> **Companion handbook:** [AGENTS.md](https://github.com/carlosvin/tanstack-fullstack-ai-template/blob/main/AGENTS.md) — structure, styling, auth snippets, Biome, testing/E2E, validation checklist, AI chat setup.
>
> **Companion skill:** `observability-and-env` — use it when changing logging, Sentry, environment schemas, or runtime config plumbing. Keep this skill focused on the architecture contract rather than the observability setup recipe.

## How to use this skill

Expand Down
18 changes: 16 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@
# 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
#
# Deployment name — controls trace sampling and log routing.
# Allowed values: development | staging | production
# ENV=development
#
# NOTE: Two separate env vars control deployment identity:
# NODE_ENV — used by Sentry's bootstrap (instrument.env.mts) to tag all events.
# ENV — used by the application (logger, sampling) for runtime behavior.
# Keep them consistent to avoid Sentry grouping events under the wrong environment
# (e.g. NODE_ENV=production + ENV=staging tags Sentry events as "production" while
# the app behaves as staging). In production, set both to the same value.
#
# Minimum pino log level.
# Allowed values: fatal | error | warn | info | debug | trace | silent
# LOG_LEVEL=info

# =============================================================================
# Authentication
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
Expand Down
Loading