Crux
GuidesObservability

Runtime setup

Bind observability drain correctly on Node, serverless, Cloudflare Workers, and Convex.

Your code ran fine on Lambda or Workers, but the run shows up as incomplete in devtools: the host froze the moment your handler returned, before telemetry finished flushing. The fix is to wrap the invocation so Crux gets a bounded final flush tied to the host's lifetime. On long-lived Node processes you don't need this: emits never block user code and delivery is batched in the background.

Choose a host binding

RuntimeWhat to doAPI
Long-lived NodeBackground batching is fine; flush on graceful shutdown.observe.flush() / observe.shutdown()
Generic serverlessWrap each invocation; await a bounded drain before return.withObservableInvocation() from @use-crux/core/observability
Node Lambda-styleSame, with context.getRemainingTimeInMillis() derived automatically.withNodeObservableInvocation() from @use-crux/core/observability/node
Cloudflare WorkersInstall defer and drain through ctx.waitUntil (no added response latency; no nodejs_compat required).withCrux() from @use-crux/cloudflare
Next.jsInstall response-finished defer and drain through the same after() port.withCrux() from @use-crux/next
ConvexUse the profile-owned boundary or first-party function wrappers.createCruxConvex().run(), action / internalAction from @use-crux/convex/server

Every wrapper reports a structured ObservabilityFlushResult. Pass onDrain to inspect it; the default reporter logs a console warning when a drain does not fully complete so incomplete flushes are never silent. Field breakdown: host lifecycle adapters.

Node (long-lived)

import { config } from "@use-crux/core";
import { observe } from "@use-crux/core/observability";

config({
  observability: {
    serverUrl: process.env.DEVTOOLS_URL,
    token: process.env.CRUX_DEVTOOLS_TOKEN,
  },
});

// On process shutdown:
await observe.shutdown();

Optional live Runtime Bridge for local inspection:

config({
  observability: {
    serverUrl: process.env.DEVTOOLS_URL,
    token: process.env.CRUX_DEVTOOLS_TOKEN,
  },
  devtools: { bridge: true },
});

Generic serverless

import { withObservableInvocation } from "@use-crux/core/observability";

export const handler = withObservableInvocation(
  async (event) => {
    // Crux work
  },
  () => ({
    remainingTimeMs: 5_000,
    onDrain(result) {
      if (result.status !== "drained")
        console.warn("incomplete Crux drain", result);
    },
  }),
);

Node Lambda

import { withNodeObservableInvocation } from "@use-crux/core/observability/node";

export const handler = withNodeObservableInvocation(
  async (event, context) => {
    // remaining time is read from context.getRemainingTimeInMillis() when present
  },
  {
    onDrain(result) {
      if (result.status !== "drained")
        console.warn("incomplete Crux drain", result);
    },
  },
);

Cloudflare Workers

import { defer } from "@use-crux/core";
import { withCrux } from "@use-crux/cloudflare";

const fetch = withCrux(
  async (request, env, ctx) => {
    defer(() => env.ANALYTICS.writeDataPoint({ blobs: [request.url] }));
    return new Response("ok");
  },
  {
    context: (_request, _env, ctx) => ctx,
    invocation: () => ({ flushTimeoutMs: 5_000 }),
  },
);

export default { fetch };

Workers isolates may serve concurrent requests without ambient async storage. The Cloudflare wrapper retains deferred work and its structured drain through one waitUntil task. It does not call observe.withHostLifecycle() around the handler.

Cross-request logical continuity still uses explicit suspend/resume carriers (observe.openRun().suspend() → persist carrier → observe.resumeRun(carrier)), not isolate-local ambient context.

Next.js

import { defer } from "@use-crux/core";
import { withCrux } from "@use-crux/next";

export const POST = withCrux(
  async () => {
    defer(() => updateSearchIndex());
    return Response.json({ ok: true });
  },
  {
    onDrain(result) {
      if (result.status !== "drained")
        console.warn("incomplete Crux drain", result);
    },
  },
);

Next's resolved after() port owns one retained task: it starts deferred callbacks only after the response finishes, then performs the bounded terminal observability drain. Exporter or reporter failure never replaces a returned response, redirect, not-found signal, or application error. withNextDefer() remains available for low-level defer-only composition.

Convex

import { createCruxConvex } from "@use-crux/convex";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";

export const crux = createCruxConvex({
  components: { crux: components.crux, agent: components.agent },
});

export const indexDocument = internalAction({
  args: { documentId: v.string() },
  handler: (ctx, args) =>
    crux.run(ctx, { documentId: args.documentId }, async ({ records }) => {
      await records.put(`document:${args.documentId}`, { indexed: true });
    }),
});

createCruxConvex().run(), action / internalAction from @use-crux/convex/server, and the agent/swarm wrappers bind a bounded final flush automatically. The default budget is a short fixed window (DEFAULT_CONVEX_OBSERVABILITY_FLUSH_TIMEOUT_MS, 3 seconds) because Convex exposes no per-invocation remaining-time API. A partial drain is reported through diagnostics and never replaces the application result or error.

For a raw Convex function not created through those helpers:

import { withObservabilityFlush } from "@use-crux/convex/observability";

export const run = internalAction({
  args: {},
  handler: withObservabilityFlush(async (ctx, args) => {
    // Crux work
  }),
});

Use ctx.crux.runAction() (not raw ctx.runAction) so child actions restore Crux context through the hidden __crux envelope. See the Convex observability guide.

Production telemetry vs local devtools

SinkRole
observability.serverUrl + tokenLocal / tunneled devtools ingest
withTelemetry() from @use-crux/otelProduction OTLP / APM

Both can run together: they are complementary sinks over the same graph spine. Do not point production traffic at a developer laptop as the only telemetry path.

On this page