Observability and tracing
Farm has three complementary observability layers:
- OpenTelemetry traces show the request timeline and export to any OTLP-compatible backend.
- OpenTelemetry metrics cover Farm's own cache, PPR, and streaming outcomes.
- Farm events expose detailed framework lifecycle data for logs, alerts, and custom integrations.
The same lifecycle events are attached to the active OpenTelemetry span, and delivered events include traceId, spanId, and traceSampled for correlation.
OpenTelemetry quick start
Install Farm's optional Node SDK setup package:
pnpm add @farm.js/otelCreate one instrumentation file at src/instrumentation.ts or instrumentation.ts:
import type { FarmInstrumentationContext } from "@farm.js/core/instrumentation";
export async function register(context: FarmInstrumentationContext) {
if (context.runtime !== "nodejs") return;
const { registerOTel } = await import("@farm.js/otel");
return registerOTel({
serviceName: "storefront",
serviceVersion: process.env.APP_VERSION,
});
}Enable Farm spans in farm.config.ts:
import { defineConfig } from "@farm.js/core";
export default defineConfig({
observability: {
tracing: {
attributes: {
"deployment.environment": process.env.NODE_ENV ?? "development",
},
},
},
});@farm.js/otel uses the OTLP HTTP trace exporter by default. Configure the destination with standard OpenTelemetry environment variables:
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com
OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer%20TOKEN"Use OTEL_EXPORTER_OTLP_TRACES_ENDPOINT when traces need a different endpoint. You can also pass a custom traceExporter, spanProcessors, sampler, resource, instrumentation list, or metricReaders to registerOTel.
Both pieces are intentional: instrumentation.ts starts an SDK/exporter, while observability.tracing tells Farm to create framework spans. Without an SDK, the OpenTelemetry API remains a no-op.
What Farm traces
| Span | Meaning |
|---|---|
GET /products/:id | Server request span with method, route, path, status, and propagated context. |
farm.render /products/:id | Completed page render. |
farm.middleware auth | Completed middleware execution. |
farm.api POST /api/products | Completed API route execution. |
farm.integration stripe.checkout.create | Completed integration operation when that event is emitted. |
farm.storage query | Completed integration database operation when that event is emitted. |
farm.ppr.refresh /dashboard | Completed PPR refresh. |
farm.plugin plugin.hook | Completed plugin hook when that event is emitted. |
Incoming W3C traceparent headers are extracted automatically. A matched Farm route renames the request span from the raw pathname to the route pattern and sets http.route. Status codes of 500 or higher and thrown errors mark spans as errors.
To wrap application work in a typed Farm-aware child span:
import { runWithFarmSpan } from "@farm.js/core/observability";
const result = await runWithFarmSpan(
"catalog.recommendations",
() => loadRecommendations(productId),
{
kind: "integration",
attributes: { "product.id": productId },
},
);Use getFarmTraceContext() when a log or provider call needs the current trace and span IDs.
What Farm measures
@farm.js/otel also subscribes to Farm's event bus and records the framework's own cache, PPR, streaming, and middleware outcomes. Auto-instrumentation only sees generic HTTP, filesystem, and database work, so this is the layer that makes cache hit rate, tag invalidation, and shell TTFB visible.
| Instrument | Kind | Attributes |
|---|---|---|
farm.cache.lookups | Counter | farm.cache.result (hit, miss, stale, dedupe, bypass), farm.cache.stale on a stale hit |
farm.cache.invalidations | Counter | farm.cache.operation (revalidateTag, updateTag, revalidatePath, invalidated) |
farm.cache.invalidated_entries | Counter | farm.cache.operation, incremented by the number of entries the operation removed |
farm.ppr.shell.lookups | Counter | farm.ppr.result (hit, miss, cached, bypass, invalidated) |
farm.ppr.refresh.duration | Histogram, ms | none |
farm.render.stream.shell.duration | Histogram, ms | none, this is shell TTFB |
farm.middleware.short_circuits | Counter | farm.route, the configured matcher rather than the request path, and farm.http.status_class |
Metric attributes are deliberately bounded. A cache key embeds the serialized arguments of the cached call, a tag is authored at runtime, and the route on cache, PPR, and streaming events is the request pathname rather than a route pattern, so none of them becomes a metric attribute. That detail is recorded as an event on the active span instead, where per-request cardinality is free and a stale serve stays attributable to the request that caused it. When observability.tracing is enabled Farm already writes those span events itself, so @farm.js/otel does not add a second copy.
Metrics need a metric reader, and Farm registers one only when you ask for it. Name an exporter with OTEL_METRICS_EXPORTER, or pass metricReaders to registerOTel:
OTEL_METRICS_EXPORTER=otlp # otlp, console, prometheus, or noneWithout it there is no meter provider, the instruments are no-ops, and the span events still work. That opt-in is deliberate: left to its own defaults the Node SDK exports metrics to localhost:4318 whether or not an app configured metrics, and with nothing listening there every recorded metric turns graceful shutdown into the exporter's retry window, around eight seconds. Traces are unaffected and keep using the OTLP trace endpoint.
Pass farmEvents to narrow or disable the mapping:
return registerOTel({
serviceName: "storefront",
// Leave Farm's event bus unsubscribed.
farmEvents: false,
// Or keep one half of it.
// farmEvents: { metrics: true, spanEvents: false },
});An app that starts its own SDK can map the bus without registerOTel. Call it after the SDK has started, because an instrument created before a meter provider is registered stays a no-op, and dispose it on shutdown so a development restart does not stack subscribers.
import { recordFarmEvents } from "@farm.js/otel";
const disposeFarmEvents = await recordFarmEvents();Instrumentation lifecycle
Farm calls register(context) once before the development server or production request runtime starts. The context contains:
mode:development,production, ortestruntime:nodejs,edge, orbunroot: the runtime working directory
register may return a cleanup function or an object with shutdown(). An exported shutdown() function is also supported. Farm runs cleanup during development server close and production graceful shutdown, after active requests drain, so batched spans are flushed before the process exits.
Keep Node-only SDK imports inside the runtime === "nodejs" branch. Edge and Bun deployments can initialize a runtime-compatible OpenTelemetry SDK in their own branch without changing Farm's tracing API.
Subscribe to events
import { onFarmEvent } from "@farm.js/core/observability";
onFarmEvent((event) => {
if (event.level === "error") {
console.error("[farm]", event.type, event);
}
});Event families
| Family | Examples |
|---|---|
| Request | request.start, request.complete, request.error |
| Server | server.start, server.ready, server.shutdown |
| Routing | route.discovered, route.matched, route.notFound, route.redirect |
| Rendering | render.start, render.complete, render.stream.shellReady, render.error |
| Cache | cache.hit, cache.miss, cache.set, cache.revalidateTag |
| PPR | ppr.shell.hit, ppr.shell.cached, ppr.shell.invalidated |
| Middleware | middleware.start, middleware.complete, middleware.shortCircuit, middleware.error |
| Integrations | integration.ready, integration.api.call.start, integration.webhook.verified |
| MCP | mcp.tool.complete (registered callback outcome, not an agent run) |
| Agents | agent.read (Markdown mirror, llms.txt, or API catalog read) |
| Integration database | storage.query.start, storage.schema.ready |
| Build | build.start, routes.generated, types.generated, manifest.generated |
The storage.query.* and storage.schema.* event names are compatibility identifiers for integration database activity. They do not describe getStorage() KV operations.
Configure in farm.config.ts
Use config-level observability when an app should log or forward events from startup.
import { defineConfig } from "@farm.js/core";
export default defineConfig({
observability: {
logs: true,
tracing: true,
events: ["cache.hit", "cache.miss", "ppr.shell.cached"],
onEvent(event) {
if (event.type === "cache.miss") {
console.log("Cache miss", event.key, event.reason);
}
},
},
});events is optional. Leave it out to receive every emitted event. Filtering event delivery does not disable span creation, the events recorded on active spans, or the @farm.js/otel metrics; use farmEvents: false for those.
Runtime subscription
Use onFarmEvent for tests, local debugging, or integration packages that want to register listeners without owning app config.
import { onFarmEvent } from "@farm.js/core/observability";
const unsubscribe = onFarmEvent((event) => {
if (event.type === "integration.webhook.failed") {
console.error(event.integration, event.reason);
}
});
unsubscribe();Useful event types
| Area | Events |
|---|---|
| Cache | cache.hit, cache.miss, cache.set, cache.stale, cache.bypass, cache.invalidated, cache.revalidatePath, cache.revalidateTag, cache.updateTag, cache.error |
| PPR | ppr.shell.hit, ppr.shell.miss, ppr.shell.cached, ppr.shell.bypass, ppr.shell.invalidated, ppr.suspense.holeDetected, ppr.refresh.start, ppr.refresh.complete, ppr.refresh.error |
| API | api.request.start, api.request.complete, api.validation.failed, api.error |
| MCP | mcp.tool.complete — configured server, transport route, tool name, outcome and duration; no tool payloads |
| Agents | agent.read — surface (markdown, llms, llms-full, api-catalog), path, Markdown view (outline, section, parts, actions), status, and the agent the User-Agent declares |
| Integrations | integration.registered, integration.config.validated, integration.ready, integration.disposed, integration.api.call.start, integration.api.call.complete, integration.webhook.verified, integration.webhook.failed |
| Middleware | middleware.start, middleware.complete, middleware.shortCircuit, middleware.error |
| Integration database | storage.query.start, storage.query.complete, storage.query.error, storage.schema.ready, storage.schema.error |
Middleware events include the matched middleware route, request pathname, middleware file/config name, duration for completes, status for short circuits, and the thrown error for failures.
MCP completion events distinguish tool errors from successful HTTP transport responses. They cover registered callbacks only, excluding authorization and SDK rejections before callback execution. See MCP tool observability for payload and coverage details.
Agent reads
agent.read fires each time an agent surface answers: a Markdown mirror or page.md (including
?outline, ?section, ?max_tokens parts, and ?actions), /llms.txt or /llms-full.txt, and
/.well-known/api-catalog. It carries the path served, never the query string, the status, and the
agent the request's User-Agent declares:
onFarmEvent((event) => {
if (event.type !== "agent.read") return;
// { surface: "markdown", route: "/pricing.md", view: "parts", status: 200,
// agent: { family: "openai", category: "agent", identity: "self-reported" } }
metrics.increment("agent_reads", { surface: event.surface, family: event.agent.family });
});agent.category is agent for user-triggered fetchers such as ChatGPT-User, Claude-User, and
Perplexity-User, and crawler for indexing and training crawlers such as GPTBot, ClaudeBot, and
PerplexityBot. The identity is self-reported: any client can send these tokens, so use it to
understand traffic, never to grant access. Farm's own reads, such as llms-full.txt reading each
page, are not reported.
classifyFarmAgent(userAgent) from @farm.js/core returns the same identity for any request, for
example in middleware.
Middleware event payloads
Middleware events are emitted for both farm.config.ts middleware entries and src/app/**/middleware.ts files in development and production.
| Event | Payload |
|---|---|
middleware.start | route, pathname, name |
middleware.complete | route, pathname, name, durationMs |
middleware.shortCircuit | route, pathname, name, status |
middleware.error | route, pathname, name, error |
import { defineConfig } from "@farm.js/core";
export default defineConfig({
observability: {
onEvent(event) {
if (event.type === "middleware.shortCircuit") {
console.log(event.pathname, event.status);
}
},
},
});Production notes
- Keep
@farm.js/otelindependencies, notdevDependencies, so it is available in the deployed server bundle. - Set
OTEL_SERVICE_NAMEor passserviceNameexplicitly; use deployment/version resource attributes for release comparisons. - Farm's production lifecycle waits for active requests and then shuts down instrumentation, allowing the batch processor to flush.
forceFlushOTel()flushes spans and, when a metrics pipeline is configured, recorded metrics. Call it fromafter()on hosts that freeze the process between requests, where the periodic metric reader never gets to export.- Filter high-volume events before shipping them to a log drain.
- Use the emitted trace and span IDs to correlate Farm events with application logs.
- Treat event payloads as operational metadata; do not put secrets in event fields.
- Watch
api.validation.failed,integration.webhook.failed,cache.error, andppr.refresh.errorin production.
Export OpenTelemetry traces and listen to correlated Farm runtime events in development and production.