iii.observe on it, and each of its invocations is offered to every
observer. This page is for the worker that hosts the function (its owner) and wants a say in how
it is observed:
- run code when an observer is added or removed (start a schedule, open a route, watch a table);
- read each observer’s
argsand hand each event only to the observers it belongs to (owner delivery); - accept the old
registerTriggercalls of a trigger type the function replaces.
registerTriggerType: a trigger type becomes a function with an observe
descriptor, and a trigger becomes an observer. To move an existing trigger type, see
Upgrading from 0.24.x.
The observe descriptor
Pass observe when you register the function:
Only the first four fields travel to the engine; the hooks stay in your process. The names follow
each SDK’s conventions:
args_format with delivery: 'auto' fails at registration with INVALID_OBSERVE_DESCRIPTOR: an
auto function’s events are its invocations, offered to every observer, so there is nothing to route
by.
Hook rules
onObserverAddedruns before the SDK acknowledges the observer to the engine. Whatever it adds to your own index is in place before the observer turnsactive, so the first event afterready()resolves reaches it.- Throwing rejects the observer: an
ObserveRejection(code, message)sets the code the observer’sready()fails with; any other error isOBSERVE_REJECTED. - The hooks of one function run one at a time, in the engine’s order. Hooks of different functions may run concurrently.
- A hook still running after 30 seconds counts as failed (
OWNER_TIMEOUT, the engine enforces the sameobservers.owner_ack_timeout_ms), and the next hook may start. onObserverRemovedruns ondispose(), policy exhaustion (max_fires, expiry), when the observing worker does not come back within its grace period, and when a reconnect no longer lists the observer.
The Observer object
Hooks receive an Observer: the observer as its owner sees it. It never exposes the observer’s
metadata, policy, or observing worker. The object stays the same for the observer’s whole lifetime, so keep
it in your own index.
Language specifics: Python has
call (blocking) and call_async; Rust has id(), args::<T>(),
args_value(), legacy(), emit(&event) -> Result<bool, Error> and call::<R>(payload).await; Go
has Args (a map), DecodeArgs(&v), Legacy, Emit(ctx, event) (bool, error) and
Call(ctx, payload) (json.RawMessage, error).
Know when you are observed
An auto function can declare hooks too, for example to start expensive work only while someone observes it.FunctionRef.isObserved() (is_observed() in Python and Rust, client.IsObserved(id)
in Go) answers in one load, so a hot path can skip building events nobody receives.
Owner delivery
Withdelivery: 'owner', invocations of your function are no longer offered to every observer. Your
code decides which observers each event is for, from their args, and hands it over:
Only your worker may invoke an owner-delivered function: the engine refuses a call to it from any
other worker (or from the engine itself) with
FORBIDDEN, so nobody can pass off an event as yours.
The SDK applies the observer’s filters in both: emit returns false, and call fails with
FILTERS_NOT_MATCHED, without sending anything. Use obs.matches({ input }) when you want to know
first. Both go through the engine, which applies the observer’s policy (max_fires, expiry,
max_in_flight), adds its metadata, and enforces the loop guard. Both throw NOT_OWNER_DELIVERED
on an auto function.
emit never waits. When you emit faster than the targets take, the observers’ queues fill and the
engine stops reading your worker’s connection until they have room again, for at most 250 ms.
Meanwhile the SDK keeps what it can in its bounded report buffer and drops the rest, counted as
BUFFER_FULL, and everything else your worker sends (its calls, its answers to calls) waits behind
its reports. After each hold the engine reads your worker’s connection for at least a quarter as
long before it holds it again, and drops what overflows meanwhile as OVERFLOW: your worker is
held at most four fifths of the time.
The event’s caller follows where you call emit: inside your function’s own handler, the event is
that invocation (its id and its caller); inside another handler, that handler is the caller; outside
any handler, only your worker. That is why the examples below trigger their own function and emit
from its handler.
Example: a timer that emits
timer::on-tick fires every every_ms milliseconds for the observers that asked for that interval.
It runs one timer per distinct interval, however many observers share it.
- Node / TypeScript
- Python
- Rust
- Go
{ every_ms: 10 }, or no every_ms) fails with
INVALID_ARGS before onObserverAdded runs.
Example: a command router that calls
chat::on-command answers chat commands. Each observer registers one command with args
{ command }; the router calls that observer’s target and returns its result as its own.
- Node / TypeScript
- Python
- Rust
- Go
/weather registers its handler and observes the router with its command. Its
return value becomes the router’s result:
Errors of obs.call
obs.call fails with the target’s own error, or with one of these codes:
Reject an observer later
An observer you accepted can become invalid, for example after a configuration reload removed the resource it watches.obs.reject(code, message) takes it out of delivery at once; the engine moves
it to error with your code and then calls onObserverRemoved. The rejection survives a reconnect.
FunctionRef.observers() (observers() in Python and Rust, client.Observers(id) in Go) lists the
active observers as your hooks saw them.
Refuse observers
Some functions should not be observed at all, for example because their input holds secrets. Register them withobserve: { allow: false } (ObserveHooks(allow=False) in Python,
Observe::auto().allow(false) in Rust, Allow: &no in Go). Every observe on them fails with
NOT_OBSERVABLE. RBAC still applies on top: an observer needs permission to invoke both the
observed function and the target.
Accept old triggers
A function that replaces a trigger type can keep serving workers that still callregisterTrigger({ type, function_id, config }). Declare the type in legacy_trigger_type. The
excerpt below changes the timer example: its handler and index stay
as they were.
registerTrigger arrives for type timer and no worker provides the timer trigger type
the old way, the engine turns the trigger into an observer of your function:
- The observer belongs to the worker that called
registerTrigger; its id is the trigger id. - It has no args and no filters, and
deliver: 'input': its target receives the event you emit, exactly as the old trigger handler received the fired payload. - The trigger’s metadata becomes the observer’s metadata sidecar.
- Your hook sees
obs.legacy = { trigger_type, config }. Validate the config as your oldTriggerHandlerdid, then accept or reject it like any observer. The old worker receives oneTriggerRegistrationResultwith the outcome, your rejection included. unregisterTrigger(orengine::unregister_trigger) disposes the observer.
registerTrigger looks for a provider: only in the
trigger’s trigger_namespace when it names one, otherwise in the registering worker’s namespace and
then in default. Your worker must run a 0.25 SDK. A worker that still registers the trigger type
the old way always wins for new registrations, so stop calling registerTriggerType for the type in
the same release that adds the function.
Against an older engine
Against an engine older than 0.25, a function with anobserve descriptor registers as a plain
function: its hooks never run, and the SDK logs one warning. Local dispatch also stays off, and
every call goes through the engine as before.