agentleFS
carlosvincarlosvin/tanstack-fullstack-ai-template/.agents/skills/observability-and-env/SKILL.md

observability-and-env

Skill3 starsChanged 7 days agoMITView on GitHub

**WORKFLOW SKILL** - Centralized env parse, structured logging, and error-tracking bootstrap behind ObservabilityService.

  • Reads credentials

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never: import vendor SDKs (pino, @sentry/*) directly in server function handlers or route loaders.

    line 82
  2. Very specific:

    Do not import webEnv from client-shared route files.

    line 111
  3. Very specific:

    Keep webServerEnv / shellSession const exports as lazy Proxy shims over the getters so existing imports keep working without triggering an …

    line 251
  4. Very specific:

    Update the build script:

    line 384

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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-template
  • reference-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
  • repository-architecture (companion) — Language-agnostic repository architecture: injected collection owners, indexes, stale-data cleanup, and resource lifetime.
    npx skills add carlosvin/tanstack-fullstack-ai-template --skill repository-architecture
  • promptable-ux (companion) — Shared promptable UX foundations plus the two chat UX variants (side panel vs prompt-first). Install when work touches chat placement or layout alongside env/logging.
    npx skills add carlosvin/tanstack-fullstack-ai-template --skill promptable-ux
  • agentic-ux (companion) — Fully agentic tool-only shell. Mantine views are declarative A2UI surfaces. Install when shell work has no domain screens.
    npx skills add carlosvin/tanstack-fullstack-ai-template --skill agentic-ux

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, or shellSession / getBrowserShellSession plumbing.

Handbook: AGENTS.md §9 — file map and usage in this repo.

Design principle — interface first

  1. Env — parse once at startup; inject serverEnv + shellSession via middleware.
  2. Logging — createServerLogger('module') in handlers; the factory binds validated env — swap pino for winston, consola, or OpenTelemetry log exporters behind the same API.
  3. Error tracking / tracing — getObservability().startSpan(...) in handlers; bootstrap in instrument.*.mts before the app entry — swap Sentry for another vendor or OpenTelemetry without touching route handlers.
  4. Never import vendor SDKs (pino, @sentry/*) directly in server function handlers or route loaders.

Skill routing

TaskLoad
Logging, error tracking, instrument.*.mts, src/env/, env leaks, shellSessionThis skill
Concrete package choices for this template (Zod, Mantine, pino, …)reference-tech-stack
New routes, entities, AI tools, repository pattern, import protectiontanstack-promptable-fullstack-app-template
Collection repositories, indexes, stale-data cleanup, resource lifetimerepository-architecture
Chat placement, prompt variants, markdown/layout UXpromptable-ux
Server fn that logs and uses context.serverEnvThis skill + architecture

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). 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.
  2. Logger options (logLevel, environment) are passed as arguments to createModuleLogger — the factory never reads process.env.
  3. Typed server context — middleware attaches serverEnv and shellSession via next({ context }). Chain that middleware on server fns that need those fields; Start infers context.* types from the chain.
  4. Browser shell session — no window.__ENV__; root loader calls getBrowserShellSession() returning the allowlisted shellSession (public env fields + app). Never return serverEnv to the client. Do not import webEnv from client-shared route files.
  5. 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 '@/services/schemas/runtimeEnv'

export const AppMetaSchema = z.object({
  name: z.string().min(1),
  version: z.string().min(1),
})

export const PromptConceptSchema = z
  .enum(['side', 'prompt-first', 'agentic'])
  .describe('Promptable UI (side) drawer vs always-visible prompt-first bar vs fully agentic tool-only shell.')

export type PromptConcept = z.infer<typeof PromptConceptSchema>

export const WebPublicEnvSchema = z.object({
  ENV: OptionalDeploymentEnvSchema,
  LOG_LEVEL: OptionalLogLevelSchema,
  SENTRY_DSN: OptionalTrimmedStringSchema,
})

export const ShellSessionSchema = WebPublicEnvSchema.extend({
  app: AppMetaSchema,
  promptConcept: PromptConceptSchema,
})
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 { envStringToUndefined } from '@/services/schemas/runtimeEnv'
import { type PromptConcept, PromptConceptSchema, ShellSessionSchema, WebPublicEnvSchema } from '@/services/schemas/shellSession'

export const WebServerEnvSchema = WebPublicEnvSchema.extend({
  DISPLAY_NAME: z.preprocess(envStringToUndefined, z.string().min(1).default('TaskHub')),
  PROMPT_CONCEPT: z.preprocess(envStringToUndefined, PromptConceptSchema.default('side' satisfies PromptConcept)),
  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: env.DISPLAY_NAME, version: pkg.version },
      promptConcept: env.PROMPT_CONCEPT,
    })
  }
  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 '@/utils/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 '@/middleware/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 () => ({
    available: getAIAdapterService().isConfigured(),
  }))

Root loader:

loader: async () => ({
  shellSession: await getBrowserShellSession(),
})

Updating call sites

observability/getObservability.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.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
  • shellSession is parsed once in webEnv.server.ts (public env + app from package.json)
  • Middleware injects serverEnv and shellSession; consumers chain middleware for inferred context.* types
  • Browser config uses getBrowserShellSession from route loaders (not window.__ENV__, not raw serverEnv)
  • SENTRY_DSN / LOG_LEVEL / ENV documented in .env.example
  • Architecture invariants from parent skill still hold: never return serverEnv from handlers; chain webEnvMiddleware for typed context.*

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.