Farm.js

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/otel

Create 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

SpanMeaning
GET /products/:idServer request span with method, route, path, status, and propagated context.
farm.render /products/:idCompleted page render.
farm.middleware authCompleted middleware execution.
farm.api POST /api/productsCompleted API route execution.
farm.integration stripe.checkout.createCompleted integration operation when that event is emitted.
farm.storage queryCompleted integration database operation when that event is emitted.
farm.ppr.refresh /dashboardCompleted PPR refresh.
farm.plugin plugin.hookCompleted 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.

InstrumentKindAttributes
farm.cache.lookupsCounterfarm.cache.result (hit, miss, stale, dedupe, bypass), farm.cache.stale on a stale hit
farm.cache.invalidationsCounterfarm.cache.operation (revalidateTag, updateTag, revalidatePath, invalidated)
farm.cache.invalidated_entriesCounterfarm.cache.operation, incremented by the number of entries the operation removed
farm.ppr.shell.lookupsCounterfarm.ppr.result (hit, miss, cached, bypass, invalidated)
farm.ppr.refresh.durationHistogram, msnone
farm.render.stream.shell.durationHistogram, msnone, this is shell TTFB
farm.middleware.short_circuitsCounterfarm.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 none

Without 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, or test
  • runtime: nodejs, edge, or bun
  • root: 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

farm.config.ts
import { onFarmEvent } from "@farm.js/core/observability";

onFarmEvent((event) => {
  if (event.level === "error") {
    console.error("[farm]", event.type, event);
  }
});

Event families

FamilyExamples
Requestrequest.start, request.complete, request.error
Serverserver.start, server.ready, server.shutdown
Routingroute.discovered, route.matched, route.notFound, route.redirect
Renderingrender.start, render.complete, render.stream.shellReady, render.error
Cachecache.hit, cache.miss, cache.set, cache.revalidateTag
PPRppr.shell.hit, ppr.shell.cached, ppr.shell.invalidated
Middlewaremiddleware.start, middleware.complete, middleware.shortCircuit, middleware.error
Integrationsintegration.ready, integration.api.call.start, integration.webhook.verified
MCPmcp.tool.complete (registered callback outcome, not an agent run)
Agentsagent.read (Markdown mirror, llms.txt, or API catalog read)
Integration databasestorage.query.start, storage.schema.ready
Buildbuild.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

AreaEvents
Cachecache.hit, cache.miss, cache.set, cache.stale, cache.bypass, cache.invalidated, cache.revalidatePath, cache.revalidateTag, cache.updateTag, cache.error
PPRppr.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
APIapi.request.start, api.request.complete, api.validation.failed, api.error
MCPmcp.tool.complete — configured server, transport route, tool name, outcome and duration; no tool payloads
Agentsagent.read — surface (markdown, llms, llms-full, api-catalog), path, Markdown view (outline, section, parts, actions), status, and the agent the User-Agent declares
Integrationsintegration.registered, integration.config.validated, integration.ready, integration.disposed, integration.api.call.start, integration.api.call.complete, integration.webhook.verified, integration.webhook.failed
Middlewaremiddleware.start, middleware.complete, middleware.shortCircuit, middleware.error
Integration databasestorage.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.

EventPayload
middleware.startroute, pathname, name
middleware.completeroute, pathname, name, durationMs
middleware.shortCircuitroute, pathname, name, status
middleware.errorroute, 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/otel in dependencies, not devDependencies, so it is available in the deployed server bundle.
  • Set OTEL_SERVICE_NAME or pass serviceName explicitly; 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 from after() 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, and ppr.refresh.error in production.