{"slug":"add-observability","title":"add-observability","summary":"Wire Sentry or OpenTelemetry into pracht server boundaries (loaders, middleware, API routes), client-side Web Vitals reporting, and a capability audit sink that records every capability dispatch with transport, outcome, latency, and verified identity. Use for \"add observability\",","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-10-19T21:17:22.93032Z","repo":{"url":"https://github.com/JoviDeCroock/pracht","stars":99,"forks":5,"license":"MIT","updatedAt":"2026-10-19T16:43:31Z"},"bodyHtml":"<hr>\n<p>name: add-observability\nversion: 1.1.3\ndescription: |\nWire Sentry or OpenTelemetry into pracht server boundaries (loaders, middleware,\nAPI routes), client-side Web Vitals reporting, and a capability audit sink that\nrecords every capability dispatch with transport, outcome, latency, and verified\nidentity.\nUse for \"add observability\", \"wire Sentry\", \"set up tracing\", \"add\nOpenTelemetry\", \"monitor Web Vitals\", \"track errors\", \"log capability calls\",\nor \"are agents calling my app\".\nallowed-tools:</p>\n<ul>\n<li>Bash</li>\n<li>Read</li>\n<li>Write</li>\n<li>Edit</li>\n<li>Grep</li>\n<li>Glob</li>\n<li>AskUserQuestion</li>\n</ul>\n<hr>\n<h1>Pracht Add Observability</h1>\n<p>Four layers, each opt-in:</p>\n<ol>\n<li><strong>Server error tracking</strong> — capture loader/middleware/API exceptions.</li>\n<li><strong>Request tracing</strong> — span per request with child spans per loader/db call.</li>\n<li><strong>Web Vitals (LCP/CLS/INP/FCP/TTFB)</strong> — client-side, posted to a beacon\nendpoint.</li>\n<li><strong>Agent traffic</strong> — one structured audit event per capability dispatch.\nOnly relevant when the app registers capabilities; skip it otherwise.</li>\n</ol>\n<p>MCP: when the pracht MCP server is registered (<code>pracht dev-mcp</code>), prefer its\n<code>inspect_agents</code>/<code>inspect_routes</code>/<code>inspect_api</code>/<code>inspect_build</code>/<code>doctor</code>/\n<code>verify</code>/<code>generate_*</code> tools over shelling out. <code>pracht inspect</code> needs the pracht\nplugin in the vite config.</p>\n<h2>Step 1: Pick the stack</h2>\n<p>Use <code>AskUserQuestion</code>:</p>\n<ul>\n<li><strong>Sentry</strong> — easiest end-to-end (errors + traces + Web Vitals).</li>\n<li><strong>OpenTelemetry + your backend</strong> (Honeycomb, Grafana, Datadog, Jaeger).</li>\n<li><strong>Custom beacon</strong> — minimal <code>fetch('/api/telemetry')</code> setup, no SaaS.</li>\n</ul>\n<p>The skill below shows Sentry and OTel patterns. Custom beacon is mentioned\nbut trivial.</p>\n<h2>Step 2: Server error tracking</h2>\n<p>Pracht already prints one line per 5xx to the server console — phase, route id,\nsource file, request path, message, and the stack — so the app is not silent\nbefore any of this is wired. What that default does not give you is a request\nid, a duration, the successful requests, or delivery to a sink off the box.\nSay so when the user only wants \"errors in my logs\": they may already have it.</p>\n<h3>Sentry path (Node adapter)</h3>\n<p>The pattern below uses <code>@sentry/node</code> and works on the <strong>Node adapter only</strong>\n— see the caveat box below for Cloudflare and Vercel Edge before installing\nanything.</p>\n<pre><code>pnpm add @sentry/node    # Node adapter only\n</code></pre>\n<p>Create <code>src/server/observability.ts</code>:</p>\n<pre><code>import { serverEnv } from \"@pracht/core/env/server\";\nimport * as Sentry from \"@sentry/node\";\n\nlet initialized = false;\nexport function initObservability() {\n  if (initialized) return;\n  initialized = true;\n  // serverEnv is read INSIDE this function, not at module scope — module-level\n  // env reads break on runtimes where env arrives per request (https://pracht.resynapse.dev/docs/env).\n  Sentry.init({\n    dsn: serverEnv.SENTRY_DSN,\n    tracesSampleRate: Number(serverEnv.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),\n    environment: serverEnv.NODE_ENV,\n  });\n}\n</code></pre>\n<p>Add a global middleware that calls <code>initObservability()</code> once (from inside\nthe handler, never at module scope) and wraps the downstream call:</p>\n<pre><code>// src/middleware/observability.ts\nimport type { MiddlewareFn } from \"@pracht/core\";\nimport * as Sentry from \"@sentry/node\";\nimport { initObservability } from \"../server/observability\";\n\nexport const middleware: MiddlewareFn = async ({ request, route }, next) =&gt; {\n  initObservability();\n  return Sentry.startSpan(\n    {\n      name: `${request.method} ${route.path}`,\n      op: \"http.server\",\n    },\n    () =&gt; next(),\n  );\n};\n</code></pre>\n<blockquote>\n<p><strong>Cloudflare / Vercel Edge caveat — be honest here.</strong> <code>@sentry/cloudflare</code>\nrequires wrapping the worker's fetch handler with <code>withSentry()</code>, but\npracht's Cloudflare adapter owns that handler — there is no user hook to\nwrap it today, so the middleware-init pattern above <strong>cannot work</strong> with\n<code>@sentry/cloudflare</code>, and <code>@sentry/node</code> does not run on Workers at all.\nDo not scaffold a pattern that can't work. Options on Cloudflare:</p>\n<ol>\n<li>Plain fetch-based event forwarding: catch errors in a wrap-around\nmiddleware and <code>fetch</code> them to Sentry's store/envelope endpoint (or any\nHTTP sink) yourself. Read the DSN via <code>serverEnv</code> inside the middleware.</li>\n<li>Wait for pracht to expose a handler-wrap hook for the adapter, then use\nSentry's Cloudflare SDK properly.</li>\n</ol>\n<p>The same applies to <code>@sentry/vercel-edge</code>: verify how the init hooks into\nthe runtime before installing; if it needs to own the handler, fall back to\noption 1. (This mirrors the OTel-edge honesty note below.)</p>\n</blockquote>\n<p>Pracht middleware is wrap-around: <code>await next()</code> invokes the rest of the\nrequest and resolves to the final <code>Response</code>, so the span naturally covers\nthe loader/handler and ends when they finish.</p>\n<p>Register it in <code>defineApp({ middleware: { observability: \"./...\" } })</code> (the\ntop-level <code>middleware</code> field is a <em>registry</em> keyed by name — not an ordered\nchain). To actually wrap requests, place <code>\"observability\"</code> first in every\nchain that should cover them:</p>\n<pre><code>defineApp({\n  middleware: { observability: \"./middleware/observability.ts\", auth: \"./middleware/auth.ts\" },\n  api: { middleware: [\"observability\"] },           // all API routes\n  routes: [\n    group({ middleware: [\"observability\"] }, [      // all pages\n      group({ middleware: [\"auth\"] }, [ /* protected routes */ ]),\n    ]),\n  ],\n});\n</code></pre>\n<p>Ordering lives in these <code>middleware: [...]</code> arrays — always place\nobservability first so it spans the rest of the chain.</p>\n<h3>OpenTelemetry path</h3>\n<pre><code>pnpm add @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/auto-instrumentations-node\n</code></pre>\n<p>Create a SDK init module that runs at server entry — for Node, use the\n<code>--require ./otel.cjs</code> flag; for Cloudflare/Vercel edge, OTel is more limited\n(use HTTP exporter directly). Surface this trade-off; don't pretend OTel\nedge is plug-and-play.</p>\n<h2>Step 3: Loader/API tracing</h2>\n<p>For each loader and API handler, wrap the body in a span.</p>\n<pre><code>import * as Sentry from \"@sentry/node\";\n\nexport async function loader({ request }) {\n  return Sentry.startSpan({ name: \"loader: dashboard\", op: \"function\" }, async () =&gt; {\n    return { /* ... */ };\n  });\n}\n</code></pre>\n<p>Auto-injection is out of scope; provide a snippet, recommend wrapping the 5-10\nslowest loaders (cross-reference with <code>audit-bundles</code> perf hotspots).</p>\n<h2>Step 4: Web Vitals on the client</h2>\n<p>Use the framework hook from a component mounted by a shared shell:</p>\n<pre><code>import { useWebVitals } from \"@pracht/core\";\n\nexport function Vitals() {\n  useWebVitals((metric) =&gt; {\n    navigator.sendBeacon?.(\n      \"/api/telemetry/vitals\",\n      JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, path: location.pathname }),\n    );\n  });\n  return null;\n}\n</code></pre>\n<p>The hook is safe during SSR, lazy-loads <code>web-vitals</code> after mount, and shares a\nsingle observer set across callers. No separate dependency, hydration guard,\nor client-only module is needed, and apps that never call it ship no metrics\nruntime.</p>\n<h2>Step 5: Beacon endpoint</h2>\n<pre><code>// src/api/telemetry/vitals.ts\nimport type { ApiRouteArgs } from \"@pracht/core\";\n\nexport async function POST({ request }: ApiRouteArgs) {\n  const body = await request.text();\n  // Forward to your destination (Sentry, Honeycomb, custom store).\n  // Keep body small; do not block on the upstream.\n  console.log(\"vitals\", body);\n  return new Response(null, { status: 204 });\n}\n</code></pre>\n<p>For Sentry users, Sentry's browser SDK can capture Web Vitals natively —\nprefer that over a custom beacon if you've gone the Sentry route.</p>\n<h2>Step 6: Agent traffic (capability apps only)</h2>\n<p>Skip when <code>pracht inspect agents --json</code> reports an empty <code>capabilities</code> list.\nThat only means there are no capability operations to audit; <code>llms.txt</code>, MCP,\nor Web Bot Auth may still expose other agent-facing surfaces. When capabilities\ndo exist, every dispatch already emits a structured <code>CapabilityAuditEvent</code> —\nnothing is instrumented per capability, a sink just has to be registered.</p>\n<pre><code>import { addCapabilityAuditListener } from \"@pracht/core/server\";\n\nconst stopAuditLog = addCapabilityAuditListener(\"audit-log\", (event) =&gt; {\n  console.log(\n    JSON.stringify({\n      msg: \"capability\",\n      at: new Date().toISOString(),\n      capability: event.capability,\n      effect: event.effect,\n      transport: event.transport, // \"http\" | \"webmcp\" | \"mcp\" | \"server\"\n      via: event.via, // causal transport for nested invokeCapability()\n      outcome: event.outcome, // \"ok\" or the envelope error code\n      status: event.status,\n      durationMs: Math.round(event.durationMs),\n      agent: event.agent?.agentDomain ?? event.agent?.keyId ?? null,\n    }),\n  );\n});\n\nif (import.meta.hot) {\n  import.meta.hot.dispose(stopAuditLog);\n}\n</code></pre>\n<p>The OTel version records a counter and a histogram keyed on\ncapability/transport/outcome, and backdates a span with\n<code>startTime: Date.now() - event.durationMs</code> (the dispatch has already\nfinished when the sink runs). The full snippet is on the agent-trust docs page.</p>\n<p>Import the module from an eagerly loaded server module. The adapter's configured\n<code>createContextFrom</code> module is one portable option: add <code>import \"./audit.ts\"</code>\nthere so the generated entry registers the sink before request handling. A\ncustom server entry can import it directly. Do not rely on an unrelated route,\nAPI route, middleware, or <code>src/server/</code> registry module; those modules are lazy\nand can miss earlier capability calls.</p>\n<p>Key properties to state when scaffolding this:</p>\n<ul>\n<li>Sinks are invoked synchronously, so keep work before the callback returns or\nreaches its first <code>await</code> cheap. A returned promise is never awaited, and a\nsynchronous throw is swallowed (first failure per sink reported via\n<code>console.warn</code>, naming it), so a broken exporter cannot fail the call.</li>\n<li><strong>Always pass a stable name</strong> as the first argument. Registering the same\nname again replaces that sink, which is what keeps a module-scope call safe\nunder dev HMR: Vite re-executes importers on every save, so an unkeyed\nregistration would add a fresh closure per keystroke and deliver every event\nN times. Never compute the name. Register the returned unsubscribe with\n<code>import.meta.hot.dispose()</code> too, so removing the module or renaming the sink\ncannot leave the old name active until the dev server restarts.</li>\n<li><code>setCapabilityAuditHook()</code> is a <strong>single slot</strong> — a second call replaces the\nfirst. Use <code>addCapabilityAuditListener()</code> whenever more than one sink exists;\nit returns an unsubscribe handle that removes only its own registration.</li>\n<li>Warning suppression is per named registration, so differently named sinks\nstill report independently when they reuse one callback.</li>\n<li>Delivery snapshots the registered sinks before callbacks run. Adding or\nreplacing a sink from inside a callback takes effect on the next dispatch,\nso the current event is never delivered twice to one name.</li>\n<li>A sink's returned promise is handed to the request's <code>waitUntil()</code>, so an\n<code>async</code> sink finishes after the response on every adapter (Cloudflare\n<code>ctx.waitUntil</code>, Netlify/Vercel <code>context.waitUntil</code>, Node's shutdown drain)\nand its rejection is reported. A batching exporter that flushes on its own\nschedule should be flushed from middleware with\n<code>args.waitUntil(exporter.flush())</code> — never reach for\n<code>context.executionContext.waitUntil</code>.</li>\n<li>Audit events cover <em>dispatch</em> only. A cross-origin 403, an unknown-capability\n404, and an unknown MCP tool name all return before dispatch and emit\nnothing, so do not build a reconnaissance alert on these events — use the\nHTTP access log for that.</li>\n</ul>\n<p>In dev the same events are already collected: the <strong>Agents</strong> section of\n<code>/_pracht</code> shows recent dispatches with transport, <code>via</code>, verified identity,\noutcome, and duration, and <code>/_pracht.json</code> exposes all of them under\n<code>agentTraffic</code>. The page counts verified identities, MCP, and MCP-caused\ncomposition as agent-attributed; shows top-level unsigned HTTP, HTTP-caused\ncomposition, and client-declared WebMCP markers separately as unverified client\ndispatches; and hides only <code>invokeCapability()</code> work with no served-request\nprovenance behind a first-party toggle, so the panel's visible count can be\nlower than the sink's.\nCounts and empty-state conclusions only cover the retained window when older\nevents have been dropped. Use it to confirm the sink sees what the panel sees\nbefore wiring a paid backend. Adapter-owned dev servers do not register this\nmiddleware: on Cloudflare <code>workerd</code>, <code>/_pracht</code> and <code>/_pracht.json</code> return 404.\nValidate the sink from its own output there; a missing panel is not a failed\naudit hook.</p>\n<h2>Step 7: Sampling and PII</h2>\n<ul>\n<li>Set <code>SENTRY_TRACES_SAMPLE_RATE</code> to a small number (0.05–0.10) in\nproduction.</li>\n<li>Scrub auth headers and cookies from breadcrumbs:\n<pre><code>Sentry.init({ beforeSend(event) { delete event.request?.headers?.cookie; return event; } });\n</code></pre>\n</li>\n<li>Never send loader return values verbatim — they often contain user data.</li>\n</ul>\n<h2>Step 8: Verify</h2>\n<ul>\n<li>Trigger a deliberate error in dev and confirm it lands in Sentry/OTel.</li>\n<li>Open a route, check the Web Vitals beacon fires (Network tab).</li>\n<li>If an audit sink was added: call a capability in dev and confirm the event\nreaches both the sink and, when the adapter exposes it, the Agents panel at\n<code>/_pracht</code>. On Cloudflare's adapter-owned dev server, validate the sink output\ndirectly because the panel does not exist.</li>\n<li>Confirm <code>pnpm test</code> and <code>pnpm e2e</code> still pass.</li>\n<li>Run <code>pracht typegen</code> if any routes were added (the beacon API route does\nnot affect page-route types, but re-run when in doubt).</li>\n<li>Run <code>pracht verify --json</code> and confirm no failures.</li>\n</ul>\n<h2>Rules</h2>\n<ol>\n<li>Confirm adapter compatibility before installing the SDK package\n(Sentry has separate packages per runtime), and never scaffold a pattern\nthe runtime can't actually run — see the Cloudflare/Vercel-edge caveat in\nStep 2. Read <code>SENTRY_DSN</code> and friends via <code>serverEnv</code> inside functions,\nnever <code>process.env</code> at module scope.</li>\n<li>Top-level <code>middleware</code> in <code>defineApp</code> is a name→path <em>registry</em>, not an\nordered chain. Place <code>\"observability\"</code> first in every <code>group({ middleware: [...] })</code> and in <code>api.middleware</code> so it wraps the rest.</li>\n<li>Web Vitals only matter for SSR/SSG/ISG routes that hydrate; SPA-only\nroutes still benefit but the values reflect the post-bootstrap state.</li>\n<li>Sample traces (≤ 10%) in production; full sampling in dev.</li>\n<li>Never send raw cookies, auth headers, or full loader payloads to a\nthird-party SaaS. The same applies to audit events: log the capability\nname, effect, transport, outcome, and agent domain — not the capability's\ninput, which is application data.</li>\n<li>Keep the synchronous part of an audit sink cheap: no CPU-heavy work or\nsynchronous network call. Returned promises are fire-and-forget; the runtime\ndoes not await them or catch their rejections.</li>\n</ol>\n<p>$ARGUMENTS</p>\n","files":[{"path":"SKILL.md","sizeBytes":14899,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-10-19T21:17:42.998819Z","sha256":"C97EFEE09CD4A8D4B20BC6F3BBB126F6499D770CF3877FCDCC948ED49C5754E0","sizeBytes":6563},"review":null,"source":{"repositoryUrl":"https://github.com/JoviDeCroock/pracht","path":"skills/add-observability","license":"MIT","commit":"8bcdd52989de3e97708e7f16221839d6313cfdac","subtreeSha":"80EF7B2BC04E505EE1E270CBF28FE850F9DB26C3BB2F91B04B844B130DE18361","lastSyncedAt":"2026-10-19T21:17:21.911347Z"},"reviewedAt":"2026-10-19T21:18:40.196233Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/add-observability"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart"},{"target":"git","command":"git clone https://github.com/JoviDeCroock/pracht.git"}]}