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, },});# workflow_id comes from the surrounding workflow or agent run, like `runId` above.workflow_id = run_idarcjet.capture( action="refund.issued", correlation_id=workflow_id, decision_id=decision.id, metadata={"amount_cents": 4999, "invoice": {"id": "inv_123"}},)capture() returns immediately and is not awaited, even on the async client.
guard.Capture(arcjet.CaptureEvent{ Action: "refund.issued", CorrelationID: runID, DecisionID: decision.ID, Metadata: arcjet.Metadata{ "invoice": map[string]any{"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:
outcome | What happened |
|---|---|
success | Policy judged the action and allowed it, and the action ran. |
denied | Policy denied the action, and the wrapped tool didn’t run. |
unavailable | The check couldn’t be completed, and the fail-closed default stopped the action. |
degraded | The check couldn’t be completed, and onGuardError: "allow" let the action run. |
error | The 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. ThePRE_TOOL_CALLhook cannot observe the tool body, so it records the decision to proceed, not the tool’s result. CrewAIguard_toolrecords 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.
Delivery
Section titled “Delivery”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
AJxxxxdiagnostics 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 defaultawait arcjet.flush(250); // custom deadline in milliseconds# `arcjet` from launch_arcjet, in a FastAPI lifespan or any async teardown.await arcjet.flush()
# `arcjet_sync` from launch_arcjet_sync, in a Flask teardown, atexit, or at# the end of a script. The method is `flush`, not `flush_sync`.arcjet_sync.flush()ctx, cancel := context.WithTimeout(context.Background(), time.Second)defer cancel()guard.Flush(ctx) // one-second deadline if ctx carries none
// At shutdown. Close flushes first, so it replaces the Flush above._ = guard.Close(ctx)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.
Serverless and edge runtimes
Section titled “Serverless and edge runtimes”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; },};Options
Section titled “Options”| Field | Description |
|---|---|
action | Required. What happened, in customer vocabulary. Convention: "resource.verb", past tense. |
correlationId / correlation_id | Optional identifier shared with related guard() and protect() calls. |
decisionId / decision_id | Optional join key for the decision that allowed the action. |
metadata | Nested JSON for correlation and analytics. Same limits as Guard metadata. |
occurredAt / occurred_at | Optional timestamp. Defaults to the time of the call. Pre-epoch values cannot be represented and are dropped. |
waitUntil | JavaScript only. Platform hook that keeps the invocation alive until the batch is sent. |
Call capture() without a client handle
Section titled “Call capture() without a client handle”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.