This file is generated from
skills/src/*.skill.yaml. Do not edit manually.
Companion skills (install if missing)
This template publishes multiple skills. If only this skill is installed, add companions before related work:
tanstack-promptable-fullstack-app-template(parent) — Architecture contract for this template — schema layers, server boundaries, AI tools, and middleware-inferred request context. Install for all non-observability TanStack work.npx skills add carlosvin/tanstack-fullstack-ai-template --skill tanstack-promptable-fullstack-app-templatereference-tech-stack(companion) — Opinionated vendor map for this template's reference app. Install when matching the demo stack's concrete packages.npx skills add carlosvin/tanstack-fullstack-ai-template --skill reference-tech-stack
Discover all skills: npx skills add carlosvin/tanstack-fullstack-ai-template --list
Observability and Environment Setup
Purpose: Establish vendor-agnostic observability plumbing — validated env
schemas, structured logging factories, and error-tracking bootstrap — following
patterns proven in production TanStack Start apps. Keeps process.env access
confined to env modules; application code receives typed, validated values as
arguments and calls an ObservabilityService interface, not a specific SDK.
Reference implementation (this template): pino for structured logs and Sentry for error tracking, both behind
src/services/observability/. Replace implementations without changing middleware contracts or handler call sites.Parent skill:
tanstack-promptable-fullstack-app-template— architecture contract (schema layers, server boundaries, middleware-inferred context). Load this skill additionally when work touches logging, env schemas, error-tracking bootstrap, orshellSession/getBrowserShellSessionplumbing.Handbook: AGENTS.md §9 — file map and usage in this repo.
Design principle — interface first
- Env — parse once at startup; inject
serverEnv+shellSessionvia middleware. - Logging —
createServerLogger('module')in handlers; the factory binds validated env — swap pino for winston, consola, or OpenTelemetry log exporters behind the same API. - Error tracking / tracing —
getObservability().startSpan(...)in handlers; bootstrap ininstrument.*.mtsbefore the app entry — swap Sentry for another vendor or OpenTelemetry without touching route handlers. - Never import vendor SDKs (
pino,@sentry/*) directly in server function handlers or route loaders.
Skill routing
| Task | Load |
|---|---|
Logging, error tracking, instrument.*.mts, src/env/, env leaks, shellSession |
This skill |
| Concrete package choices for this template (Zod, Mantine, pino, …) | reference-tech-stack |
| New routes, entities, AI tools, repository pattern, import protection | tanstack-promptable-fullstack-app-template |
Server fn that logs and uses context.serverEnv |
This skill + architecture |
Key invariants (do not violate)
process.envis read only insrc/env/*.ts(schema parse) and in a singleBootstrapEnvSchema.parse(process.env)call insideinstrument.env.mts(Sentry bootstrap — runs before the app entry). Parsed values are cached singletons — once per process, lazily on first access so platform-injected env vars (e.g. Netlify AI Gateway) are visible before the parse runs.- Logger options (
logLevel,environment) are passed as arguments tocreateModuleLogger— the factory never readsprocess.env. - Typed server context — middleware attaches
serverEnvandshellSessionvianext({ context }). Chain that middleware on server fns that need those fields; Start inferscontext.*types from the chain. - Browser shell session — no
window.__ENV__; root loader callsgetBrowserShellSession()returning the allowlistedshellSession(public env fields +app). Never returnserverEnvto the client. Do not importwebEnvfrom client-shared route files. - The root pino logger is created once per process (lazy singleton); all
module loggers are
child()instances of it.
File layout
src/env/ # server-only — importProtection denies '**/env/**' in the client graph
webEnv.server.ts # WebServerEnvSchema + webServerEnv/shellSession; parsed once
src/services/schemas/
runtimeEnv.ts # client-safe: DeploymentEnv, LogLevel, shared preprocessors
shellSession.ts # client-safe: AppMeta / WebPublicEnv / ShellSession schemas
src/utils/
logger.ts # createModuleLogger(name, { environment, logLevel? })
serverLogger.ts # createServerLogger(name) — binds webServerEnv
src/middleware/
webEnv.ts # webEnvMiddleware: injects serverEnv, shellSession
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/services/schemas/runtimeEnv.ts
Shared deployment enums and preprocessors used by web env (and any future pipeline env). Client-safe (no secrets, no env access) — may ship in the browser bundle. Reference implementation uses Zod — ArkType or Valibot work if you keep the same parse-once-at-startup contract.
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/services/schemas/shellSession.ts
Browser-safe projection schemas (public env + app meta). Client-safe by
contract — no secrets, no env access, no dotenv — so they may ship in the
browser bundle. On the server, getShellSession() derives the runtime value
lazily from validated webServerEnv (see webEnv.server.ts below).
import { z } from 'zod'
import { OptionalDeploymentEnvSchema, OptionalLogLevelSchema, OptionalTrimmedStringSchema } from './runtimeEnv'
export const AppMetaSchema = z.object({
name: z.string().min(1),
version: z.string().min(1),
})
export const WebPublicEnvSchema = z.object({
ENV: OptionalDeploymentEnvSchema,
LOG_LEVEL: OptionalLogLevelSchema,
SENTRY_DSN: OptionalTrimmedStringSchema,
})
export const ShellSessionSchema = WebPublicEnvSchema.extend({ app: AppMetaSchema })
export type ShellSession = z.infer<typeof ShellSessionSchema>
src/env/webEnv.server.ts
Server-only — never import from client-shared modules (the importProtection
client tripwire **/env/** fails the build otherwise). Parsed once per
process. WebServerEnvSchema adds secrets on top of the public schemas.
import { z } from 'zod'
import pkg from '../../package.json' with { type: 'json' }
import { ShellSessionSchema, WebPublicEnvSchema } from '../services/schemas/shellSession'
export const WebServerEnvSchema = WebPublicEnvSchema.extend({
AUTH_HEADER_NAME: z.string().optional(),
// ... secrets
})
export type WebServerEnv = z.infer<typeof WebServerEnvSchema>
let cachedWebServerEnv: WebServerEnv | undefined
/** Validated server env — parsed once per process on first access. */
export function getWebServerEnv(): WebServerEnv {
if (!cachedWebServerEnv) {
cachedWebServerEnv = WebServerEnvSchema.parse(process.env)
}
return cachedWebServerEnv
}
let cachedShellSession: ShellSession | undefined
/** Browser-safe shell session derived from validated server env + package.json. */
export function getShellSession(): ShellSession {
if (!cachedShellSession) {
const env = getWebServerEnv()
cachedShellSession = ShellSessionSchema.parse({
ENV: env.ENV,
LOG_LEVEL: env.LOG_LEVEL,
SENTRY_DSN: env.SENTRY_DSN,
app: { name: pkg.name, version: pkg.version },
})
}
return cachedShellSession
}
Keep webServerEnv / shellSession const exports as lazy Proxy shims over the
getters so existing imports keep working without triggering an eager parse.
Add required secrets to WebServerEnvSchema only — they must never appear in ShellSessionSchema.
src/utils/logger.ts
Structured logger factory. No process.env access — env values come from the caller.
Reference implementation uses pino; keep the same createModuleLogger(name, options) signature when swapping vendors.
import pino, { type Logger } from 'pino'
import pinoPretty from 'pino-pretty'
import type { DeploymentEnv, LogLevel } from '../services/schemas/runtimeEnv'
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'
// In-process pino-pretty stream — never `pino.transport()`: its worker
// thread references `__dirname` and crashes bundled ESM server output.
// Root at 'trace' so child level overrides are never filtered out
rootLogger = useTtyPretty
? pino({ level: 'trace' }, pinoPretty({ 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.
import { webServerEnv } from '../env/webEnv.server'
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:
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.
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.
Reference implementation uses @sentry/tanstackstart-react; rename initSentry to match your vendor or wrap it inside ObservabilityService.
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)
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:
"build": "vite build && tsc -p tsconfig.instrument.json && cp package.json .output/server/package.json"
webEnvMiddleware (typed context via chaining)
Injects startup-validated serverEnv and shellSession. Types come from
next({ context }) + .middleware([webEnvMiddleware]) on consumers.
// src/middleware/webEnv.ts
import { createMiddleware } from '@tanstack/react-start'
import { getShellSession, getWebServerEnv } from '../env/webEnv.server'
import { authMiddleware } from './auth'
export const webEnvMiddleware = createMiddleware()
.middleware([authMiddleware])
.server(({ next }) =>
next({
context: {
serverEnv: getWebServerEnv(),
shellSession: getShellSession(),
},
}),
)
// src/start.ts — sole global entry
export const startInstance = createStart(() => ({
requestMiddleware: [webEnvMiddleware],
}))
Handlers that need env on context chain the middleware:
export const getBrowserShellSession = createServerFn({ method: 'GET' })
.middleware([webEnvMiddleware])
.handler(async ({ context }) => context.shellSession)
export const getAIAvailability = createServerFn({ method: 'GET' })
.middleware([webEnvMiddleware])
.handler(async ({ context }) => ({
available: Boolean(context.serverEnv.GEMINI_API_KEY),
}))
Root loader:
loader: async () => ({
shellSession: await getBrowserShellSession(),
})
Updating call sites
observability/index.ts — replace process.env.SENTRY_DSN with getShellSession():
import { getShellSession } from '../../env/webEnv.server'
export function getObservability(options: GetObservabilityOptions): ObservabilityService {
const dsn = options.shellSession?.SENTRY_DSN ?? getShellSession().SENTRY_DSN
// ...
}
middleware/auth.ts — replace process.env.AUTH_HEADER_NAME with
webServerEnv:
import { webServerEnv } from '../env/webEnv.server'
const AUTH_HEADER_NAME = webServerEnv.AUTH_HEADER_NAME ?? 'Authorization'
Checklist
-
process.envappears only insrc/env/*.tsand one bootstrap schema parse ininstrument.env.mts -
createModuleLogger/createServerLoggernever callprocess.env -
instrument.server.mtsusesresolveSentryBootstrapEnv()+initSentry(), andpnpm buildemits.output/server/instrument.*.mjs -
package.jsonis copied next to the emitted instrument bundle so version import works -
shellSessionis parsed once inwebEnv.server.ts(public env + app from package.json) - Middleware injects
serverEnvandshellSession; consumers chain middleware for inferredcontext.*types - Browser config uses
getBrowserShellSessionfrom route loaders (notwindow.__ENV__, not rawserverEnv) -
SENTRY_DSN/LOG_LEVEL/ENVdocumented in.env.example - Architecture invariants from parent skill still hold: never return
serverEnvfrom handlers; chainwebEnvMiddlewarefor typedcontext.*