Skip to main content
Every function is observable without any code on your side: other workers call 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 args and hand each event only to the observers it belongs to (owner delivery);
  • accept the old registerTrigger calls of a trigger type the function replaces.
In 0.25 this 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

  • onObserverAdded runs before the SDK acknowledges the observer to the engine. Whatever it adds to your own index is in place before the observer turns active, so the first event after ready() resolves reaches it.
  • Throwing rejects the observer: an ObserveRejection(code, message) sets the code the observer’s ready() fails with; any other error is OBSERVE_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 same observers.owner_ack_timeout_ms), and the next hook may start.
  • onObserverRemoved runs on dispose(), 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.
Because the worker calls its own function, the call runs in-process (local dispatch) and reaches the engine only when an observer’s filters match.

Owner delivery

With delivery: '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.
Another worker observes it with args and, optionally, filters:
An observer with args the schema refuses ({ 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.
A worker that handles /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 with observe: { 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 call registerTrigger({ 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.
When a 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 old TriggerHandler did, then accept or reject it like any observer. The old worker receives one TriggerRegistrationResult with the outcome, your rejection included.
  • unregisterTrigger (or engine::unregister_trigger) disposes the observer.
The engine looks for the stand-in function where 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 an observe 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.