observe runs a function (the target) each time another function (the source) has an event:
allow: false).
sourceis the function to observe, plus optional filters that select which events reach the target.targetis the id of the function to run. It can be any function except the source itself. The observer stayspendinguntil 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 toorders::create, a regular
function registered by another worker.
React to every call
After each successful call oforders::create, run emails::send-receipt with the order.
deliver: "input" makes the target receive the input of orders::create (the order) as its
argument.
- Node / TypeScript
- Python
- Rust
- Go
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 runsalerts::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).
- Node / TypeScript
- Python
- Rust
- Go
Observe errors or all calls
By default, the target runs only after a successful call of the source. To change this, seton 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):
- Node / TypeScript
- Python
- Rust
- Go
on: "complete". The envelope’s status is success or
error.
Target arguments
With the defaultdeliver: "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
callerthen has thefunction_idandinvocation_idof that function.
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 exceptfunction_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:
- Node / TypeScript
- Python
- Rust
- Go
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 withinput, 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.
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.- Node / TypeScript
- Python
- Rust
- Go
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 thedefault 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. Withdeliver: "output", the target getsnull. To give the target the event, usedeliver: "input".
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.
- Node / TypeScript
- Python
- Rust
- Go
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:
- Node / TypeScript
- Python
- Rust
- Go
iii.observer.dropped.total
metric, by function and reason.
Observer states
ready()resolves when the observer isactiveand rejects with the reason’s code when it turnserrorordisposedfirst (ObserveRejectionin Node, Python, and Rust;*iii.ObserveRejectionErrorin Go).onStateChange(listener)(on_state_changein Python and Rust,OnStateChangein 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 callsdispose()on the handle, or it callsengine::observers::disposewith theobserver_idfrom the envelope. To stop after a number of events or after a time, setmax_firesorexpires_in_msin the policy.- An observer belongs to the connection that created it. When that worker disconnects, its observers
become
suspendedforobservers.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 becomedisposed. - When a worker reconnects, its SDK sends each of its observers to the engine again, with the same
id. The engine remembers a
disposedobserver forobservers.disposed_retention_ms(1 hour by default). In this time, the engine does not create the observer again, and the handle becomesdisposed. Somax_firesandexpires_in_msstay 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, useonStateChangeon its handle.engine::observers::listandgetdo not show disposed observers. - When the observed function’s worker disconnects, its observers go back to
pendingand becomeactiveagain 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 becomeserror, 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 captureinput 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.
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 acaller: 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 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 anobserve_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.
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.- This worker registered the function with a handler. HTTP-invoked functions always go through the engine. Local dispatch is on for the function.
- The engine confirmed local dispatch for the registration. This requires a 0.25 engine with
observers.local_dispatchon. If the worker connects through RBAC, its session must have nomiddleware_function_id, and the session must be allowed to call the function. - The call resolves to the namespace the function is registered in.
- The action is
Voidor a normal call. AnEnqueuecall always goes through the engine. - The call does not ask for the remote path.
- The call is fewer than 64 local calls deep. Deeper calls go through the engine.
- Node / TypeScript
- Python
- Rust
- Go
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: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. Otherwiseobserve 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,
observefails withUNSUPPORTED. - 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.