Skip to content

Capture events

guard() decides whether an action is allowed. capture() records that the action happened. Use it for the facts you want in a security trace but do not want to gate – a refund issued, a document exported, a tool call completed.

Captures are visibility data. They never change a guard() or protect() conclusion and never set hasFailedOpen() / HasFailedOpen().

arcjet.capture({
action: "refund.issued",
correlationId: runId,
decisionId: decision.id,
metadata: {
invoice: { id: "inv_123", amount: 4200 },
refunded: true,
},
});

action is required. Convention is "resource.verb" in the past tense. An event with no usable action is dropped, because it records nothing. Optional fields join the event to other work: a correlationId / correlation_id shared with guard() and protect(), a decisionId / decision_id from the decision that allowed the action, metadata with the same nested-JSON shape as Guard metadata, and occurredAt / occurred_at when the action happened (defaults to now). Every event is sent with source "sdk".

Do not put secrets or PII in metadata. It is stored as untrusted data.

Automatic captures from a framework wrapper

Section titled “Automatic captures from a framework wrapper”

A framework integration captures an event for you around every guarded call, so the record covers the action whatever happened to it. Each one carries an outcome in its metadata:

outcomeWhat happened
successPolicy judged the action and allowed it, and the action ran.
deniedPolicy denied the action, and the wrapped tool didn’t run.
unavailableThe check couldn’t be completed, and the fail-closed default stopped the action.
degradedThe check couldn’t be completed, and onGuardError: "allow" let the action run.
errorThe action ran and then threw. This takes precedence over the other outcomes.

success is reserved for an action policy judged in full, which is what makes degraded worth reading. Without it, an action that ran during an outage because you configured the wrapper to fail open would be indistinguishable from one Arcjet approved – and those are the calls a post-incident review most needs to find.

The value is capture and Sequence metadata. It never changes the decision. conclusion remains ALLOW or DENY. The decision has no outcome field. You don’t set outcome. The helper writes it after your metadata so a caller key can’t overwrite what the helper recorded.

A degraded event uses decisionId / decision_id to tell the two incomplete judgments apart:

  • Present: policy judged the action in part. Guard returned a decision with an ID, but an input the decision needed could not be resolved.
  • Absent: policy judged none of it. The helper had no decision, a failed-open decision with an empty ID, or an answer it could not read.

An unreadable decision isn’t passed into capture. The event emits without a decision ID.

onGuardError / on_guard_error is "allow" or "deny". The default is "deny": an incomplete check blocks the action and records unavailable. If the action runs and then throws, the event records error, even when the judgment was incomplete.

A direct capture() / Capture call doesn’t set outcome.

Most wrappers that sit around an effect record these five values. These surfaces record success when the action proceeds, including when on_guard_error="allow" and policy did not judge the action fully. They don’t record degraded:

  • CrewAI register_arcjet_hooks. The PRE_TOOL_CALL hook cannot observe the tool body, so it records the decision to proceed, not the tool’s result. CrewAI guard_tool records the five values.
  • Claude Agent SDK PostToolUse. That hook is observe-only after the tool ran, and it cannot distinguish a tool error from a result the model is meant to read.

Observe-only LangChain ArcjetCaptureHandler and ArcjetAsyncCaptureHandler don’t go through the checkpoint engine.

Filter on outcome to answer two questions an audit asks: which actions ran without a decision behind them, and which denials the agent hit.

Capture is best-effort and never blocks or throws into application code:

  • Events are queued in memory and sent in batches on size or a short delay.
  • The queue is bounded. When it is full, the newest event is dropped.
  • A failed batch is never retried. A stale visibility event is worth less than the capacity a retry would consume.
  • Nothing is dropped silently. Local failures use stable AJxxxx diagnostics on the client’s logger.

Typical defaults are a 1000-event queue, batches of up to 50, and a 100 ms delay. flush() / Flush waits for the events that were outstanding when you called it. The default deadline is one second. On expiry, remaining events belonging to that flush are dropped (AJ3003) and the client stays usable.

There is no close() on the JavaScript or Python clients. Flushing is the only shutdown step that changes what gets delivered. The Go client also exposes Close, which flushes and then releases the locally compiled WebAssembly, so at shutdown you call Close rather than both.

await arcjet.flush(); // one-second deadline by default
await arcjet.flush(250); // custom deadline in milliseconds

flush() is optional and repeatable. Events captured while a flush is waiting are not its responsibility, so calling flush() per request in a concurrent server cannot discard another request’s telemetry.

A runtime that freezes or terminates between invocations can lose whatever is still batched. JavaScript waitUntil extends the invocation; it does not disable batching. Events still batch, and the hook is handed a promise that settles once they have been sent.

export default {
async fetch(request, env, ctx) {
arcjet.capture({
action: "refund.issued",
waitUntil: (promise) => ctx.waitUntil(promise),
});
return new Response("ok");
},
};

Arcjet discovers Vercel’s request context on its own, so waitUntil is not needed there. Every other per-invocation hook – Cloudflare’s ExecutionContext included – has to be passed in, because a module-scoped client cannot reach it.

Where capture() is called too deep to reach the platform context, flush() at the end of the handler instead:

export default {
async fetch(request, env, ctx) {
const response = await handle(request);
ctx.waitUntil(arcjet.flush());
return response;
},
};
FieldDescription
actionRequired. What happened, in customer vocabulary. Convention: "resource.verb", past tense.
correlationId / correlation_idOptional identifier shared with related guard() and protect() calls.
decisionId / decision_idOptional join key for the decision that allowed the action.
metadataNested JSON for correlation and analytics. Same limits as Guard metadata.
occurredAt / occurred_atOptional timestamp. Defaults to the time of the call. Pre-epoch values cannot be represented and are dropped.
waitUntilJavaScript only. Platform hook that keeps the invocation alive until the batch is sent.

Passing the client explicitly is the recommended path. When capture() is called too deep in the application to receive a handle, register a client at startup and import the free capture() function. See registering a client and the test client.