Skip to main content
observe runs a function (the target) each time another function (the source) has an event:
By default, an event is a successful call of the source. Some sources make their own events, and their behavior may differ. In these cases, consult the documentation of the specific worker. Any worker can observe any function, unless otherwise restricted by RBAC or by the source itself (allow: false).
  • source is the function to observe, plus optional filters that select which events reach the target.
  • target is the id of the function to run. It can be any function except the source itself. The observer stays pending until the source and the target are registered.
worker.observe replaces registerTrigger for sources that are functions. See Upgrading from 0.24.x to move existing triggers.
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 examples on this page build on each other. They all react to orders::create, a regular function registered by another worker.

React to every call

After each successful call of orders::create, run emails::send-receipt with the order. deliver: "input" makes the target receive the input of orders::create (the order) as its argument.
From this point, emails::send-receipt runs after each successful call of orders::create. 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 observer can miss events that occur before ready() resolves.

Narrow it with a filter

This observer runs alerts::big-order only for orders with a total of 500 or more. A filter on the source selects which events reach the target. The source does not set deliver, so the target receives the Observed envelope. With the default capture, the envelope has the order (input), the result (output), the caller (caller), and the duration of the call (duration_ms).
Both observers watch the same function, independently. The next examples add policy, chains, and engine events.

Observe errors or all calls

By default, the target runs only after a successful call of the source. To change this, set on in the source: A cancelled call (for example, its caller stopped waiting) does not run the target, for any value of on. The engine counts it as a dropped event with the reason CANCELLED in the iii.observer.dropped.total metric. The default capture is ["input", "output", "caller"], without error. If capture does not include error, the envelope has no error field, and the target does not get the error. To give the target the error, add error to capture. This observer runs alerts::order-failed after each failed call of orders::create. The target receives the Observed envelope with the order (input) and the error (error):
To run the target after each call, use on: "complete". The envelope’s status is success or error.

Target arguments

With the default deliver: "envelope", the target’s first argument is the Observed envelope. The envelope always has the ids, status, and the timings. It has input, output, error, caller, and metadata only when capture includes them. output is present only after a successful call. When a source makes its own events, the invocation_id depends on where the source sends the event:
  • From the handler of the source function: the id of that call. Events sent during the same call have the same invocation_id.
  • From other code: a new id for each event; for example when a source calls another function that then performs the emit. The envelope’s caller then has the function_id and invocation_id of that function.
Engine-hosted event sources give each event a new id. With deliver: "input" or deliver: "output", the target’s first argument is that value alone. If the value is absent (for example, output after a failed call), the argument is null. The observer’s metadata option is the target’s second argument (the metadata parameter in Python, MetadataFromContext(ctx) in Go). It is different from the metadata field of the envelope, which is the metadata of the observed call. iii discards the target’s return value, unless the source documents otherwise. Observers of the target can see it as the output of the target’s call. See Chain reactions.

Source options

All fields are optional except function_id: A filter can read any field, including those not defined in capture. capture sets only which fields the target function receives.

Source args

Some sources take args, for example a path for a file watcher or an interval for a schedule. The documentation of the source lists its args. For example, files::watch, the example source in Observable functions, takes { path }. It is not part of iii:
args tell the source which events to make (the changes to one file). Filters select from those events, for example only changes that leave the file non-empty: { "input.size_bytes": { "$gt": 0 } }. The engine checks args against the args_format of the source. The observer fails with INVALID_ARGS when the args do not match, or when the source has no args_format and the args are not empty.

Filters

A filter is a JSON object. Each key is a path in the event that starts with input, output, error, caller, or metadata. Each value is an operator object, for example { "$eq": "EUR" }. A literal value such as "EUR" is not allowed. The operators are $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $prefix, $and, $or, $not, and $any.
When the observer is created, the SDK and the engine check the filter. If it is not valid, the observer fails with INVALID_FILTER. The engine also checks the input.* and output.* paths against the request_format and response_format of the function. If a closed schema does not have a path, the observer fails with INVALID_FILTER_PATH. If the function is not registered yet, the engine does this check when the function registers. The engine and each SDK have their own filter implementation. All of them pass the same set of test cases, so a filter matches the same events in all of them. See the filter language reference for the operators, the rules for absent and null values, and the limits.

Chain reactions

A call of a target is a normal call, so another observer can observe the target. In a chain, each observer observes the target of the observer before it. In this example, the inventory worker reserves the items of each new order, and the shipping worker schedules a shipment for each reservation. Each worker registers its own function and observer.
inventory::reserve returns the reservation. With deliver: "output", shipping::schedule receives that return value as its argument. To add a step, observe the last function in the chain. To branch, add two observers to the same function. Each step adds 1 to the depth of the event. When the depth is more than observers.max_observe_depth (8 by default), the engine drops the event with LOOP_GUARD. See Loop guard.

Engine-hosted event sources

The engine hosts event sources as functions in the default namespace. Observe them like any function, with filters on the event. They replace the trigger types of the engine. No function runs for these events. The engine creates each event, puts it in input, and handles it as a successful call with no output. This has two results:
  • The calls never fail. An observer with on: "error" is accepted, but its target never runs.
  • There is no output. With deliver: "output", the target gets null. To give the target the event, use deliver: "input".
Their request_format is closed at the top level, so an input.* filter path that is not in the event fails with INVALID_FILTER_PATH. Only the engine makes their events. A direct call to one of them fails with FORBIDDEN.

Policy and metadata

observe takes an optional third argument, the policy. The policy sets limits for the observer itself. An activation is one run of the target. This observer runs alerts::payment-failed after each failed call of payments::charge with an amount of 500 or more. It runs at most 4 activations at the same time, and it gives each activation { "team": "billing" } as metadata:
Each event runs the target at most once. The engine does not retry. A failure of the target does not change the observed call. The engine counts each dropped event in the iii.observer.dropped.total metric, by function and reason.

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 each state change with its reason. Listeners run outside the connection’s read path, so a listener can call the worker.
  • dispose() (Dispose(ctx) in Go) removes the observer. A disposed observer is not replayed. A target that runs in the observing worker can stop its own observer: it calls dispose() on the handle, or it calls engine::observers::dispose with the observer_id from the envelope. To stop after a number of events or after a time, set max_fires or expires_in_ms in the policy.
  • An observer belongs to the connection that created it. When that worker disconnects, its observers become suspended for observers.observer_grace_ms (30 s by default). If the worker reconnects in this time, it keeps its observers and their counters. If not, the observers become disposed.
  • When a worker reconnects, its SDK sends each of its observers to the engine again, with the same id. The engine remembers a disposed observer for observers.disposed_retention_ms (1 hour by default). In this time, the engine does not create the observer again, and the handle becomes disposed. So max_fires and expires_in_ms stay in effect after a reconnect. After this time, the engine creates the observer again with new counters, as after an engine restart.
  • To know when an observer becomes disposed, and why, use onStateChange on its handle. engine::observers::list and get do not show disposed observers.
  • When the observed function’s worker disconnects, its observers go back to pending and become active again when the function is registered again.

Inspect observers

The engine lists observers through three built-in functions: The output does not include the observer’s metadata option. With RBAC, a session sees only the observers of functions that it is allowed to invoke.

Errors

When the engine cannot create an observer, the observer becomes error, and ready() fails with one of these codes:

Event size

The engine keeps one copy of each observed call for all observers of the function. The copy has each field that any of these observers captures or filters on. If observers capture input and output, both count: a function that returns its 40 MiB input makes an event of about 80 MiB. The engine does not deliver an event in these cases:
  • 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 calls that run in the source’s own worker (local dispatch), 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. OVERFLOW is different: the queues were full when the event arrived. An event of the same size can go through when the queues have space again. 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.

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.
The engine sets worker_id and worker_name from the calling connection. It replaces any values that the SDK sends. The calling SDK sends function_id, invocation_id, and parent (the caller of that handler, one level only). Use these 3 fields for routing and filters only. 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, 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. 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, a call made after an await and outside withInvocation gets the depth of the deepest invocation that runs on the page. When several invocations run at the same time, this depth can be too high, and the loop guard can stop an observer chain early. While the page is offline, the functions that it hosts run locally, to a maximum of 256 nested calls. A call past that limit fails immediately with LOCAL_DEPTH_EXCEEDED.

Loop guard

An observer’s target can call the observed function again, directly or through other observers. This makes a loop. To stop loops, every invocation carries an observe_depth, which increases by 1 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 send the depth. For their calls, the engine uses the largest depth of the invocations still in flight on that worker.

Local dispatch

When a worker calls a function that it registered, the SDK runs the handler in the same process. The call does not go through the engine. If the function has no observers, the SDK sends nothing to the engine. If the function has observers, the SDK applies their filters and sends the matching invocations to the engine in batches. Observers get local calls and engine calls in the same way.
The hosting SDK reports the observed values of local calls 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.
A call runs locally when all of these hold:
  1. This worker registered the function with a handler. HTTP-invoked functions always go through the engine. Local dispatch is on for the function.
  2. The engine confirmed local dispatch for the registration. This requires a 0.25 engine with observers.local_dispatch on. If the worker connects through RBAC, its session must have no middleware_function_id, and the session must be allowed to call the function.
  3. The call resolves to the namespace the function is registered in.
  4. The action is Void or a normal call. An Enqueue call always goes through the engine.
  5. The call does not ask for the remote path.
  6. The call is fewer than 64 local calls deep. Deeper calls go through the engine.
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.

Create an observable function

To make something observable, register a function for it and call that function whenever the thing happens. Each call is an event for its observers:
A function can also send different events to different observers. For example, a file watcher can watch only the paths that its observers give in args. To write this type of function, see Observable functions. You observe it in the same way, sometimes with args.

Authorization

If your worker connects with RBAC, it can observe a function only when it is allowed to call both the observed function and the target. Otherwise observe fails with FORBIDDEN. When the observed function or the target registers again later, the engine does the check again. To change or deny observers in the RBAC configuration, see Worker manager.

Compatibility

  • With an engine older than 0.25, observe fails with UNSUPPORTED.
  • 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.