Observe a function
observe(a, b) means: when a runs, b runs.
- Node / TypeScript
- Python
- Rust
- Go
a), iii runs the target (b) with the call’s input, its result and its caller.
The examples on this page react to orders::create, a function that another worker registers.
- Node / TypeScript
- Python
- Rust
- Go
What can be observed
The observed function can be any function on the engine: one that your team wrote, or one a worker hosts to expose something that happens, such as an HTTP request (http::on-request), a queue
message (queue::on-message) or a row change (database::on-row-changed). The target is any
registered function. Any worker can observe any function, private ones included, unless the function
is registered with observable: false or RBAC restricts it (see Authorization).
By default, an observer is accepted as soon as it is added, and its target runs on every call its
capture fires on and its filters match. The worker that hosts the function can override each part
of that with a hook: onObserverAdded sets something up for the observer, or rejects it;
onObservationMatch decides which calls reach which observer; and onObservationDeliver delivers
the calls itself and decides what the caller gets back (see
Hooks). If unsure, refer to that worker’s
documentation.
Defaults
- The target runs after each successful call of the observed function.
- The target receives the
Observedpayload of the call, with itsinput, itsoutput, and itscaller. - The caller of the observed function gets its result without waiting for the target. Each call runs the target at most once, and a failure of the target does not change the call.
- The observer stays
pendinguntil both functions are registered, in any order. The handle thatobservereturns reports this in itsstateproperty, and itsready()resolves once the observer isactive. Calls that happen before then can be missed. - You call
observeonce at startup, likeregisterFunction. It returns immediately, even before the connection is up: the SDK sends the observer when it connects and replays it with the same id after every reconnect.
This page is about running your function when another one runs. To decide what happens when the
functions your worker hosts are observed, see Observable
functions. Coming from 0.25 triggers?
registerTrigger
becomes observe: see Upgrading from 0.25.x to 0.26.x.Args and filters
An observer can carryargs and filters. args go to the worker that hosts the function, which
reads them to set something up for the observer (a route, a database trigger, a queue consumer) and
to filter which calls the observer receives. This is custom logic: each function defines the args
its use case needs. filters are standard: the iii engine applies them to each call, the same way
for every function.
- Node / TypeScript
- Python
- Rust
- Go
Args
The engine checksargs against the args_format of the function, a JSON Schema the function’s
worker declares when it registers the function. The observer fails with INVALID_ARGS when the
args do not match, or when the function has no args_format and the args are not empty. The
worker can also refuse an observer whose args it cannot serve, with a code of its own (for example
UNKNOWN_DB or HTTP_ROUTE_CONFLICT). Each function documents its args: see its worker’s page on
workers.iii.dev.
Filters
A filter is a JSON object. Each key is a path in the call 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.
- Node / TypeScript
- Python
- Rust
- Go
Operators
The operators of one operator object must all match:{ "input.total": { "$gte": 100, "$lt": 500 } } is a range. See the
filter language reference for the full rules and the limits.
Comparison
$eq, $ne, $in, $nin, $gt, $gte, $lt, $lte. Equality is JSON deep equality, with no
coercion between types. Ordering applies within one type only: numbers compare numerically, strings
by code point.
Existence and null
$exists takes a boolean and matches on the presence of the path. null is a value:
{ "$exists": true } matches null, and { "$eq": null } matches only null, and will not match
a missing path. Only $ne, $nin, and $exists: false match a missing path.
Strings
$prefix matches a string that starts with the operand.
Arrays
A path does not read inside an array. Wheninput.items is an array of objects, the path
input.items.sku is a missing path: it does not read the sku of each item. To match on the
elements, use $any. It matches when at least one element matches its operand. For scalar elements,
the operand is an operator object. For object elements, the operand is a filter whose paths start at
@, the current element.
Logical
$and and $or take a non-empty array of filters. $not takes one filter.
$and, $or and $not are clauses of a filter. They cannot go inside an operator object on a
path.Validation
When the observer is created, the SDK and the engine check the filter. If it is not valid, the observer fails withINVALID_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.
What the target receives
capture sets what the target receives, and with it after which calls the target runs.
The payload
The target receives theObserved payload of the call. It always has the ids, status, and the
timings. It has input, output, error, caller, and metadata when capture
includes them and the call has them: output only after a successful call, error only after a
failed one. A field that is not captured, or that the call does not have, is absent, never null.
This differs from 0.25, which sent "output": null to an observer that captured output when a
successful call reported no output.
- Node / TypeScript
- Python
- Rust
- Go
capture
capture lists the fields the payload carries: any of input, output, error, caller, and
metadata. The default is ["input", "output", "caller"].
metadata is opt-in: the call’s metadata reaches the target only when capture names it. Earlier
0.26 release candidates of this change added metadata to the default; 0.26 does not. To route
calls by their metadata (for example a for_observer key), use the hosting worker’s
onObservationMatch or onObservationDeliver
hook, which receives the call’s metadata whatever the observers capture. A filter can
also read metadata.* without metadata in capture.
The fields also decide after which calls the target runs:
inputandoutputrun it after a successful call.errorruns it after a failed call.callerandmetadataadd to the payload only. Acapturemust name at least one ofinput,output, anderror; one with onlycallerormetadatafails withINVALID_ARGS. The exception iscapture: [], which runs the target after successful calls with no payload fields.[]runs the target after a successful call, with the ids,status, and the timings only: no payload moves to it. An observer that only counts calls uses it.
The default does not include
error, so a default observer runs after successful calls only. A
call the engine cancels (for example, a call it routes whose caller stopped waiting) does not run
the target, for any capture. The engine counts it as a dropped call with the reason CANCELLED
in the iii.observer.dropped.total metric. A call the hosting worker runs in its own process is
not cancelled when its caller stops waiting: its handler runs to the end, and its outcome reaches
the observers like any other.
Once capture has decided which outcomes run the target, a filter can read any field
of the call, including the fields that capture leaves out of the payload.
To keep observed calls small, capture only what the target reads. See
Observed call size.
To run the target after failed calls with their error, capture error:
- Node / TypeScript
- Python
- Rust
- Go
input and error: the target runs
after both, and the payload’s status says which.
Failed calls with their input
To run the target after failed calls only, and still get each call’s input, captureinput and
error and keep only the calls that have an error with a filter:
- Node / TypeScript
- Python
- Rust
- Go
capture: ["input", "error"] fires on both outcomes, and the filter drops the successful calls
before they reach the observing worker. The engine evaluates it for the calls it routes and starts
no target for a successful one. For the calls a worker on a 0.26 SDK runs in-process, that SDK
evaluates it and does not report a successful call to the engine at all. After each failed call
the target gets status: "error", the input, and the error. Capturing error alone would
fire on failures only, but its payload would not carry the input.
The observing worker needs a 0.26 SDK. An observer from a worker without the observe.capture
feature keeps the 0.25 semantics and fires on successful calls only, so with this filter it never
fires (see Compatibility).
Metadata
The observer’smetadata option, set in the policy, reaches the target
function as its second argument on every activation.
The payload’s
metadata field is the metadata of the observed call, present only when capture
names metadata: the default leaves it out. The observer’s metadata option is the target
function’s second argument.The target function’s return value
The iii engine does not use the return values of target functions. However, an observable function can useonObservationDeliver
to get that return value from the target function and use it, for example to answer an HTTP request
or to acknowledge a queue message. The function’s documentation says what its target functions must
return. Likewise, observers of a target function receive the return value as the output of its
call. See Chain reactions.
Policy and metadata
An observer can carry a policy. The policy sets limits for the observer itself. An activation is one run of the target.- Node / TypeScript
- Python
- Rust
- Go
iii.observer.dropped.total metric, by function and reason.
Observer handle
observe returns a handle with the observer’s id and state, and three methods:
ready()resolves when the observer isactive. It rejects with anObserveRejectionthat carries the reason’s code when the observer turnserrorordisposedfirst.onStateChange(listener)reports each state change with its reason, and returns a function that unsubscribes. Listeners run outside the connection’s read path, so a listener can call the worker.dispose()removes the observer. The SDK does not replay a disposed observer after a reconnect. On an observer the engine refused,dispose()has nothing to remove and makes no request to the engine.
- Node / TypeScript
- Python
- Rust
- Go
dispose() on the
handle, or it calls engine::observers::dispose with the observer_id from the payload. To stop
after a number of calls or after a time, set max_fires or expires_in_ms in the
policy.
Observer states
- 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. - A restarted worker is a new connection. With an
idof your own (see Policy and metadata), the restarted worker’s observe of thatidreplaces itssuspendedobserver, when both connections have the same worker name, namespace and principal: both on ports withoutrbac, or both RBAC sessions whose auth function gave them the same non-emptycontext. An observer with a randomidstayssuspendeduntil the grace period ends. - Set the worker name with
III_WORKER_NAMEor the worker name option of the SDK; a worker that iii compose starts getsIII_WORKER_NAMEfrom it. The default name,hostname:pid, changes when the process restarts, so the restarted worker’s observe of theidfails withOBSERVER_ID_CONFLICTuntil the grace period ends. - When a worker reconnects, its SDK sends each of its observers to the engine again, with the same
id. The engine keeps a record of 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. - An observer with neither
max_firesnorexpires_in_msis not lost when its worker is away for longer than the grace period. When the engine answers that it was disposed because the worker was away (OBSERVER_DISCONNECTED), the SDK observes it again at once, with the sameid, source, target, metadata and options, and logs a line naming theid. The handle goes topendingand thenactive, and does not becomedisposed. The engine creates a new observer, so its count of fires starts again at 0, and the worker that hosts the observed function gets it as a new observer (onObserverAddedruns). An observer you are disposing is not observed again. - To know when an observer becomes
disposed, and why, useonStateChangeon its handle.engine::observers::listandgetdo not show disposed observers. - When the worker that hosts the observed function disconnects, its observers go back to
pendingand becomeactiveagain when the function is registered again.
Engine-hosted event sources
The engine hosts event sources as functions in thedefault namespace, and you observe them with
filters on the event. A worker in any namespace observes engine::* and configuration::* sources
without naming a namespace. For observability::* sources, a worker in another namespace names
namespace: "default".
Each of them is a private function whose handler returns the event. So
input and output are both
the event: capture: ["input"] or capture: ["output"] gives the target the event in the payload.
Their calls never fail, so an observer that captures error but neither input nor output is
accepted, but its target never runs. 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 calls them. A call from a worker fails with
FORBIDDEN. The SDK exports their ids as EngineSources, for example
EngineSources.WORKERS_CHANGED.
- Node / TypeScript
- Python
- Rust
- Go
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, andcapture: ["output"] hands it the
previous step’s return value as the payload’s output.
- Node / TypeScript
- Python
- Rust
- Go
reserve_items / reserveItems and schedule_shipment / scheduleShipment take
the Observed payload and read its input or output.
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 call: see Loop guard.
Inspect observers
The engine lists observers through three built-in functions:
The output does not include the observer’s
metadata option. With RBAC, the output holds only the
observers of functions the session’s grants cover.
engine::on-observers-changed.
Errors
When an observer cannot be created, it becomeserror, and ready() fails with one of these codes:
A worker can also reject an observer with its own code, which its documentation lists (for example
HTTP_ROUTE_CONFLICT or UNKNOWN_DB).
Observed call 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 takes a 40 MiB input and returns a 40 MiB result makes an observed call
of about 80 MiB. The engine does not deliver an observed call 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 function’s own worker (local dispatch), that SDK’s report of the call 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.
To keep observed calls small, capture only what the target reads, for example capture: ["input"].
For payloads larger than these limits, pass a
channel reference in place of the data.
OVERFLOW is a different drop: the queues were full when the call arrived. A call of the same
size can go through when the queues have space again.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 SDK propagates the caller automatically from the handler you are in, and exposes the current
invocation (function_id, invocation_id, caller, activation, observe_depth) as
worker.currentInvocation(), carried across await.
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 calls 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 an engine at 0.25 or
later with
observers.local_dispatchon. If the worker connects through RBAC, its session must have nomiddleware_function_id(unless the worker hosts the function privately), 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 set
dispatch: "remote". - 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.
Authorization
If your worker connects with RBAC, it can observe a function only when its grants (expose_functions, namespaces) cover both the observed function and the target. Otherwise
observe fails with FORBIDDEN. A private function is observable by any session whose grants cover
it. 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. - With a 0.25 engine, observers work. A function registered with
args_format,private: true, orobservable: false, or that setsonObservationMatchoronObservationDeliver, needs a 0.26 engine: the SDK does not register it on a 0.25 engine, so its observers staypending. The event functions of workers such ashttpandqueueare such functions. - Functions hosted by workers on older SDKs are observable. The engine evaluates the filters of every routed call, whichever SDK hosts the function, and the hosting SDK reports its own local calls.
- Workers on SDKs older than 0.25 cannot observe.
onanddeliverin an observer’s source are deprecated. A 0.26 engine translates them to their 0.25 behavior and logs a warning, and an observer from a worker on a 0.25 SDK or a 0.26 release candidate SDK always gets the 0.25 semantics. Replace them withcapture: see Legacyonanddeliver. Support will be removed in a future release.- A 0.26 SDK connected to a 0.25 engine or a 0.26 release candidate engine sends each observer with
the legacy
onitscaptureimplies, and logs once that this is deprecated. Upgrade the engine. See the compatibility matrix.