Crux
GuidesObservability

Middleware & Hooks

Global middleware and per-prompt lifecycle hooks for logging, timing, and error handling.

You want to log, time, or wrap every model call in one place, without touching each prompt. Middleware gives you one global wrapper around every generation; per-prompt lifecycle hooks cover the cases where the logic belongs to a specific prompt.

Middleware

Middleware wraps every generate() and stream() call across all prompts. Use it for logging, timing, error handling, or custom retry logic.

crux.config.ts
config({
  generation: {
    middleware: async (args, next) => {
      const start = Date.now();
      try {
        const result = await next(args);
        console.log(`${args.promptId}: ${Date.now() - start}ms`);
        return result;
      } catch (error) {
        console.error(`${args.promptId} failed after ${Date.now() - start}ms`);
        throw error;
      }
    },
  },
});

Middleware receives { promptId, preparedArgs } and a next function. It can inspect or modify args, measure timing, transform results, or handle errors.

Standalone setup (when not using config()):

import { updateHooks } from '@use-crux/core'
updateHooks({ middleware: async (args, next) => { ... } })

Lifecycle hooks

Per-prompt hooks give you fine-grained observability on individual prompts:

prompts.ts
prompt({
  hooks: {
    onPrepare: (args) => {
      console.log("System tokens:", args.systemTokens);
      if (args.droppedContexts.length > 0) {
        console.warn(
          "Dropped:",
          args.droppedContexts.map((c) => c.source),
        );
      }
    },
    onGenerate: (args) => {
      console.log(`${args.promptId}: ${args.durationMs}ms`);
    },
    onError: (args) => {
      reportError(args.promptId, args.error);
    },
  },
});

Middleware runs globally on every call. Hooks run per-prompt. Use middleware for cross-cutting concerns (logging, timing) and hooks for prompt-specific observability.

Hook argument types

Each lifecycle hook receives a typed argument object: onPrepare gets PrepareHookArgs (promptId, systemTokens, droppedContexts), onGenerate gets GenerateHookArgs (promptId, durationMs, adapter-specific result), and onError gets ErrorHookArgs (promptId, error, and the phase that failed, so you can distinguish context-composition errors from LLM-call errors). Full shapes: prompt lifecycle hook argument types.

Instrumentation hooks

For deeper observability into memory, scoring, and agent operations, Crux provides graph-record subscribers. These are wired up when you explicitly enable a local devtools server/tunnel via config({ devtools: { serverUrl } }), you don't need to configure them manually.

The instrumentation hooks cover three areas:

Memory hooks

  • onMemoryRead: fired when a memory store is queried (includes the query and number of results)
  • onMemoryWrite: fired when new entries are written to memory
  • onCompactStart / onCompactEnd: fired around memory compaction operations, useful for tracking compaction duration and token savings

Budget and context hooks

  • onBudgetCheck: fired during token budget evaluation, reports which contexts fit and which are candidates for dropping
  • onBlackboardUpdate: fired when a shared blackboard (cross-prompt state) is updated

Agent hooks

  • onHandoffPrepare: fired when an agent prepares to hand off to another agent or sub-prompt
  • onDelegateStart / onDelegateComplete: fired around agent delegation calls, tracking which sub-agent was invoked and its result
  • onJudgeResult: fired when an LLM judge returns a score, includes the metric ID, score, and reasoning

Instrumentation hooks are designed for observability, not control flow. They cannot modify the data flowing through them, use middleware if you need to transform args or results.

Production logging

Middleware gives you structured data about every generate() call. Use it to forward traces to your observability stack:

crux.config.ts
config({
  generation: {
    middleware: async (args, next) => {
      const start = Date.now();
      try {
        const result = await next(args);
        myLogger.send({
          type: "llm.generate",
          promptId: args.promptId,
          model: result._meta?.model,
          durationMs: Date.now() - start,
          tokens: result._meta?.usage,
          cost: result._meta?.cost,
          status: "success",
        });
        return result;
      } catch (error) {
        myLogger.send({
          type: "llm.generate",
          promptId: args.promptId,
          durationMs: Date.now() - start,
          status: "error",
          error: error.message,
        });
        throw error;
      }
    },
  },
});

This works with any logging service, Datadog, Sentry, Posthog, or a simple console.log. The _meta object on results contains normalized model, usage, and cost data regardless of which adapter you used.

This approach captures generate() and stream() calls. For full observability covering memory operations, compaction, tool calls, and flows, use devtools locally or the explicit OpenTelemetry plugin in production.

Next steps

On this page