Skip to main content
Every function in iii is observable. An observer watches a function (the source) and invokes another function (the target) for each invocation or event that passes its filters. Any worker can observe any function it is allowed to invoke, and the function’s worker needs no code for it.
  • source is what is observed: the function_id, its args (only for functions whose owner routes events, see Auto and owner delivery), declarative filters, and what to capture.
  • target is the id of a registered function. There is no inline handler: register the target first.
  • options holds the observer’s id, its delivery policy, and metadata.
iii.observe replaces registerTrigger for sources that are functions. See Upgrading from 0.24.x to move existing triggers and trigger types.
The http, cron, queue, state, stream, pubsub, and database workers still publish trigger types in 0.25. Keep binding to them with registerTrigger until they ship source functions; the deprecation warning it logs is informational. You can observe any regular function, the engine-hosted event sources, and providers that already moved to observable functions.

Observe a function

The example below sends large orders to an alert function. orders::create is a regular function registered by another worker; it needs no changes to be observed. The target receives one Observed envelope per matching invocation.
observe returns a handle at once, also before the connection is up. The SDK sends the observer when it connects and replays it with the same id after every reconnect, so you call it once at startup, like registerFunction. The browser SDK (iii-browser-sdk) has the same observe API as Node.

The source

A filter may read any field, whatever the observer captures: capture only decides what travels to the target.

Auto and owner delivery

How events reach observers depends on the observed function. Its owner chooses when it registers the function; observers use the same observe call in both cases. Owner-delivered functions document their args. For example, timer::on-tick, the example function you write in Observable functions (it is not built in), takes { every_ms }:
To write such a function yourself, see Creating Workers / Observable functions.

What the target receives

With the default deliver: 'envelope', the target’s first argument is the Observed envelope. Fields left out of capture are absent. With deliver: 'input' or deliver: 'output', the target receives that value alone, which suits a target written as a regular function of the observed payload. An observer of an owner-delivered function that the owner reaches with obs.call receives the owner’s payload instead (for example an HTTP request), and its return value goes back to the owner. The observer’s metadata option arrives as the activation’s metadata sidecar, the same second argument (metadata in Python, MetadataFromContext(ctx) in Go) that trigger metadata used. The target’s return value is discarded for auto delivery and obs.emit.
For local calls and obs.emit, the hosting SDK reports the observed values to the engine. Values JSON cannot hold, such as a number no double can represent, arrive as null, and a lone surrogate in a string or key arrives as U+FFFD. Filters read the values the same way. Reports are at most once: a report lost with a dropped connection is not sent again.

Event size

The engine keeps one copy of an observed invocation for all observers of its function, with every field any of them captures or filters on. Capturing input and output counts both: a function that echoes a 40 MiB input makes an event of about 80 MiB. An event too large on its own is not delivered, however idle the engine is:
  • The fields the engine keeps come to more than 64 MiB (approximate in-memory size).
  • The activation is larger than the target worker accepts in one message: 64 MiB, or 16 MiB for browser workers and some older SDKs. See Payload size.
  • For local calls and obs.emit, the hosting SDK’s report of the event is larger than its 8 MiB report buffer.
Each such event counts as a TOO_LARGE drop in the iii.observer.dropped.total metric. In the first two cases it also counts in the observer’s dropped counts (engine::observers::list), and the engine logs a warning naming the observer, the function, the size, and the limit, at most once a minute per observer. An owner’s obs.call whose activation the target cannot receive fails with payload_too_large. OVERFLOW is different: the queues were full when the event arrived, and an event of the same size can get through once they drain. To keep events small, capture only what the target reads, for example capture: ["input"]. With deliver: "input" or deliver: "output", set a matching capture too: captured fields are kept even when the target receives one value. For payloads larger than these limits, pass a channel reference instead of the data.

Filters

Filters are declarative JSON objects over the event: input, output, error, caller, and metadata. Each path takes an operator object; there is no literal shorthand.
The engine validates a filter when the observer is created (INVALID_FILTER) and checks its input.* and output.* paths against the function’s request_format and response_format (INVALID_FILTER_PATH for a path a closed schema does not have). The same evaluator runs in the engine and in every SDK, so a filter matches the same events wherever it runs. See the filter language reference for the operators, the rules for absent and null values, and the limits.

Policy and metadata

The third argument configures the observer itself:
Delivery is at most once. An observer failure never affects the observed invocation, and every dropped event is counted per function and reason in the iii.observer.dropped.total metric. An event waiting in several observers’ queues is held once, however many observers it matched.

Observer states

  • ready() resolves when the observer is active and rejects with the reason’s code when it turns error or disposed first (ObserveRejection in Node, Python, and Rust; *iii.ObserveRejectionError in Go).
  • onStateChange(listener) (on_state_change in Python and Rust, OnStateChange in Go) reports every transition with its reason. Listeners never run on the connection’s read path, so they may call the client.
  • dispose() removes the observer. A disposed observer is not replayed.
  • An observer belongs to the connection that created it. When that worker disconnects, its observers are suspended for observers.observer_grace_ms (30 s by default); a worker that reconnects in time keeps them, with their counters. After that they are disposed, and a worker that reconnects later gets that answer when its SDK replays them, so max_fires and expires_in_ms still hold. The engine keeps the answer for observers.disposed_retention_ms (1 hour by default). After that, a returning worker’s replay creates its observers again, as after an engine restart.
  • When the observed function’s worker disconnects, its observers go back to pending and become active again when the function is registered again.

Caller and causality

Every invocation carries a caller: the worker that made the call and, when the call was made from inside a handler, the function and invocation it was made from.
worker_id and worker_name are stamped by the engine from the calling connection and cannot be forged. function_id, invocation_id, and parent (the caller of that handler, one level only) come from the calling SDK, so use them for routing and filtering, not for authorization. A filter such as { "caller.parent.worker_name": { "$ne": "search-indexer" } } stops an indexer from reacting to its own writes. When the engine activates a target (auto delivery and obs.emit), the target’s caller is the engine (worker_name: "iii-engine", no worker_id), with the observed function and invocation as function_id and invocation_id. For obs.call, the caller is the owner’s worker and handler. In both cases the current invocation’s activation holds the observer_id and activation_id. The SDKs propagate the caller automatically from the handler you are in. Each SDK also exposes the current invocation (function_id, invocation_id, caller, activation, observe_depth): In the browser, calls made after an await outside withInvocation carry the depth of the deepest invocation running on the page, so with several invocations running the depth is over-counted: an observer chain may stop early at the loop guard, and some concurrent local calls go through the engine (with the same results). While the page is offline, functions it hosts still run locally, up to 256 nested local calls; past that a call fails at once with LOCAL_DEPTH_EXCEEDED.

Loop guard

An observer whose target calls the observed function again (directly or through other observers) would loop forever. Every invocation carries an observe_depth, which grows by one on each activation. Activations deeper than observers.max_observe_depth (8 by default) are dropped and counted as LOOP_GUARD, and an observer whose target is the observed function itself is rejected at creation with SELF_TARGET. Workers on SDKs older than 0.25 do not propagate the depth; through them the engine estimates it from their activations still in flight.

Local dispatch

A worker that calls a function it registered itself runs the handler in-process, without a round trip through the engine. When nobody observes the function, such a call puts nothing on the wire. When observers match, the SDK evaluates their filters itself and reports the matching invocations to the engine in batches, so observers see local and routed calls alike. A call runs locally when all of these hold:
  1. The function was registered by this worker with a handler (not an HTTP-invoked function), and local dispatch was not turned off for it.
  2. The engine confirmed the registration for local dispatch (a 0.25 engine, observers.local_dispatch on, and a session without RBAC middleware_function_id that may invoke the function).
  3. The call resolves to the namespace the function is registered in.
  4. The action is not Enqueue (a Void call runs locally, fire-and-forget).
  5. The call does not ask for the remote path.
A local call returns the same result, error shape, and timeout as a remote one. The differences are in Upgrading from 0.24.x. To keep a function on the engine path:
To turn it off for the whole engine, set observers.local_dispatch: false in the engine configuration.

Engine-hosted event sources

The engine hosts event sources as auto-delivered functions in the default namespace. Observe them like any function, with filters on the event. They replace the engine’s own trigger types, which keep firing in 0.25. These events have no output, so use deliver: 'input' to hand the event itself to the target. Their request_format is closed at the top level, so a misspelled input.* filter path fails with INVALID_FILTER_PATH. Calling one of them directly fails with FORBIDDEN: their events are the engine’s to emit.

Inspect observers

The engine lists observers through three built-in functions: The output never includes an observer’s metadata. Under RBAC, a session sees only the observers of functions it may invoke.

Errors

An observer that cannot be created turns error, and ready() rejects with one of these codes:

Authorization

To observe a function, a session must be allowed to invoke both the observed function and the target (RBAC expose_functions). The check is repeated when the target registers again on another connection. The RBAC block of a worker-manager listener accepts an on_observer_registration_function_id hook, with the same contract as on_trigger_registration_function_id: it may rewrite function_id, target_function_id, args, filters, and metadata, or deny the observer (FORBIDDEN). See Worker manager.

Compatibility

  • Against an engine older than 0.25, observe fails with UNSUPPORTED and nothing runs locally.
  • Functions hosted by workers on older SDKs are still observable: their invocations are routed through the engine, which evaluates the filters.
  • Workers on older SDKs cannot observe. They keep using registerTrigger.