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
In this repository6 other files it gives its agents.
- AGENTS.mdAGENTS.md
- agentic-uxSkill
- promptable-uxSkill
- reference-tech-stackSkill
- repository-architectureSkill
- tanstack-promptable-fullstack-app-templateSkill
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
- Very specific: line 82
Never: import vendor SDKs (pino, @sentry/*) directly in server function handlers or route loaders.
- Very specific: line 111
Do not import webEnv from client-shared route files.
- Very specific: line 251
Keep webServerEnv / shellSession const exports as lazy Proxy shims over the getters so existing imports keep working without triggering an …
- Very specific: line 384
Update the build script:
≈ “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.
Files
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-stackrepository-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-architecturepromptable-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-uxagentic-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, 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 |
| Collection repositories, indexes, stale-data cleanup, resource lifetime | repository-architecture |
| Chat placement, prompt variants, markdown/layout UX | promptable-ux |
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.jsonsrc/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.envappears only insrc/env/*.tsand one bootstrap schema parse ininstrument.env.mtscreateModuleLogger/createServerLoggernever callprocess.envinstrument.server.mtsusesresolveSentryBootstrapEnv()+initSentry(), andpnpm buildemits.output/server/instrument.*.mjspackage.jsonis copied next to the emitted instrument bundle so version import worksshellSessionis 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.*
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
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.

