Observability introspection (traces, logs, metrics, sampling rules, alerts, rollups) is owned
end-to-end by the iii-observability worker. The engine injects this worker automatically; do not
declare it in
config.yaml, engine.workers, or containers.Connection ports
The engine binds its worker and stream WebSockets. Project HTTP routes and observability use their own workers:
The console UI (ADE) runs on
3113 by default. Add it to a project
with iii trigger compose::add worker=ade.
Connection flow
A worker opens the SDK WebSocket (defaultws://127.0.0.1:49134). On connect the engine assigns the
worker a UUID and sends a WorkerRegistered { worker_id, reattach_token, features } frame carrying
it. The worker sends the registrations it holds in memory (each RegisterFunction, RegisterTrigger,
and RegisterTriggerType it intends to expose, then each Observe it owns) and calls
engine::workers::register to publish its own metadata (runtime, version, OS, PID, isolation,
optional namespace, an optional one-line description, and the protocol features it supports),
which the engine acknowledges with a RegisterWorkerResult. See
Feature negotiation for what features turns on.
The engine answers every request on the worker listener (/, /otel, /ws/channels/{id}),
including the WebSocket upgrade, with an x-iii-capabilities response header: optional transport
capabilities as comma-separated tokens. Clients ignore tokens they do not know. tcp-nodelay means
the engine’s end of the socket has TCP_NODELAY set, so the worker may disable Nagle on its end too.
A worker must not disable Nagle without this token: with Nagle on the engine’s end only, a worker
that answers immediately leaves the engine’s next small frame waiting for the worker’s delayed ACK
(~40 ms on Linux), which is slower than keeping Nagle on both ends. Older engines send no header.
The connection is bidirectional from that point on: the engine pushes InvokeFunction frames at the
worker, and the worker pushes InvocationResult, additional registrations, or unregistrations back.
A connection gets its namespace from the engine::workers::register call.
A client can send registration messages before this call. The engine holds these messages until it
knows the namespace. It does not register them in default and move them later.
When engine::workers::register arrives, the engine registers the worker, sets the connection
namespace, and processes the held messages in arrival order. If the registration timeout expires
first, the engine sets the connection namespace to default. See Namespaces.
Feature negotiation
Observers and local dispatch (0.25) are negotiated per connection, so a 0.24 SDK keeps working against a 0.25 engine, and a 0.25 SDK against a 0.24 engine.- Engine to worker.
WorkerRegisteredcarriesfeatures. A 0.25 engine sends["observe.v1", "local-dispatch.v1"]; older engines omit the field. - Worker to engine. The worker lists the features it supports in the
featuresarray of itsengine::workers::registerpayload. A pre-0.25 SDK sends none. Besides the two above, a worker can declarelarge-messages.v1, which the engine never advertises (see the rules below).
- The first
engine::workers::registerthat carries a non-emptyfeatureslist fixes the connection’s features. Later declarations are ignored (logged atdebug). The engine keeps at most 32 names, and drops names longer than 64 bytes. A malformed list costs the worker its features, never its registration. - The engine pushes
FunctionRegistered,ObserverAdded, andObserverRemovedonly to connections that declaredobserve.v1.ObserveResultandObserverStateChangedanswer a connection’s ownObserveand go to it regardless. Old SDKs never receive a message type they cannot decode. - The engine adds
caller,observe_depth, andactivationto anInvokeFunctiononly when the receiving connection declaredobserve.v1. - The engine decodes every worker-to-engine observe message from any connection.
large-messages.v1commits the connection to receiving JSON messages up to 64 MiB (67,108,864 bytes of serialized envelope), sent as one WebSocket text frame or as a fragmented message. The engine then sends it messages up to that size whateverversionit reports, except toruntime: "browser"(16 MiB). Without it, the engine falls back to the reported SDK version; no other feature counts. The Rust, Node, Python, and Go SDKs declare it from 0.25.0; the browser SDK does not. See Payload size.- An SDK enables
observe()only when the engine advertisedobserve.v1; against an older engine,observe()fails withUNSUPPORTED. An SDK runs one of its own functions in-process only after the engine confirmed that function withFunctionRegistered { local_dispatch: true }. - If the namespace grace timer drains before
engine::workers::registerarrives, the connection is treated as legacy until it does; the engine then sendsFunctionRegisteredfor the functions the connection already registered.
Message types
Every frame is a JSON object discriminated bytype (the lowercased variant name, e.g.
registerfunction). The full set, defined on Message in
engine/src/protocol.rs:
RegisterFunction
id is required. description, request_format, response_format, and metadata are optional
and feed the iii console and the agent-readable skills. invocation is reserved for external HTTP
functions (HttpInvocationRef); leave it out (or null) for in-process handlers. Since 0.25 the
engine omits request_format and response_format when it sends a registration that has none,
instead of writing null.
observe descriptor
Every function is observable. A function that wants to say how its observers are delivered adds an
optional observe object (0.25). The SDK-local callbacks (onObserverAdded, onObserverRemoved)
never travel; only these four fields do. The example’s cron::on-tick is illustrative: the cron
worker still publishes the cron trigger type in 0.25.
A function without
observe is observable with delivery: auto and accepts no args. The engine
answers a descriptor with args_format and delivery: auto, or with an unknown delivery value,
with RegistrationRejected { code: "INVALID_OBSERVE_DESCRIPTOR", function_id } and does not register
the function. See Observable functions for the provider
side.
When the registration is accepted, a connection that declared observe.v1 receives
FunctionRegistered.
RegisterTrigger
Deprecated in 0.25: use
Observe instead. RegisterTrigger keeps working through a
live trigger-type provider, or through the legacy shim once the provider moved to a
function. See Upgrading from 0.24.x.config is the per-trigger-type configuration; the shape is defined by whatever worker advertised
that trigger_type (e.g. http for http triggers). The engine responds with a
TriggerRegistrationResult carrying an optional error: ErrorBody.
namespace specifies the namespace of the target function. It uses the same namespace system as
worker registration. Usually, it has the same value as the worker namespace.
A trigger can call a function in a different namespace. For this reason, RegisterTrigger includes
the target namespace. If this field is not present, function_id resolves in default. A present
field must be a non-empty string; the engine rejects null and any other non-string value.
trigger_namespace specifies where to find the trigger type’s provider. It is a different question
from namespace: one locates the target function, the other locates the provider that fires it.
If trigger_namespace is not present, the engine resolves it in two steps: the registering
connection’s namespace first, then default. This is not the same as sending "default". The two
steps let a project register its own provider for a trigger type id that the engine also provides,
while a worker that names nothing still reaches the engine’s provider.
If trigger_namespace is present, resolution is strict: that namespace or nothing. A binding that
names a namespace is never moved to another provider.
When a provider registers in a namespace after a binding already resolved to default, the engine
moves that binding to the new provider. Start order does not decide which provider serves a
project.
These fields target math::add in default:
math::add in orders:
RegisterTriggerType
Deprecated in 0.25: register a function with an
observe descriptor
instead. RegisterTriggerType keeps working in this release. See Upgrading from
0.24.x.trigger_request_format is the JSON Schema for the trigger’s per-binding config.
call_request_format is the JSON Schema for the payload delivered to bound functions when the
trigger fires.
namespace is the namespace this provider serves. If it is not present, the engine files the
provider under the registering connection’s namespace. Providers are keyed by
(namespace, trigger_type_id), so two workers in different namespaces can advertise the same
trigger_type id without replacing each other.
The engine’s own providers (http, cron, state, stream) register in default.
UnregisterTriggerType
debug log)
otherwise. Bindings of that type then move to a fallback provider, are taken by a function that
declares the type as its legacy_trigger_type (see Legacy shim), or stay stored,
unserved, until a provider returns.
namespace is optional and names the namespace the provider was filed under. If it is not present,
the engine uses the sending connection’s namespace, as for RegisterTriggerType. Like the other
trigger messages, UnregisterTriggerType is deprecated.
InvokeFunction
invocation_id is omitted on Void invocations (the worker has no result channel to reply on).
The optional metadata field on a trigger registration (null / None in the examples above)
is arbitrary JSON stored with the trigger and delivered to the receiving function as a
distinct argument alongside the payload. It is useful for providing contextual information about
the trigger or execution context to the receiving function. A target function shared by many
triggers can use it to recover which registration fired and with what context.
metadata can be provided both via registerTrigger and direct trigger() invocations.
traceparent and baggage contain W3C trace
context. action is the routing flag (see Trigger actions below);
absent / null means synchronous.
namespace is optional and selects the namespace function_id resolves in. Omit the field to
resolve in default; omission also keeps a peer that never sends it wire compatible. Send a
non-empty string when the field is present. The engine rejects null and any other non-string
value. See Namespaces for the resolution rules.
Observe fields
0.25 adds three optional fields. Their meaning depends on the direction:
The engine adds these fields to a frame it sends only when the receiving connection declared
observe.v1. It reads them leniently: a malformed value counts as absent instead of failing the
call. caller.parent is one level only; a parent inside parent is not part of the format and the
engine drops it. caller.worker_* cannot be forged; function_id, invocation_id, and parent are
asserted by the calling SDK and are useful for routing and filtering, not for authorization. The
engine still injects _caller_worker_id into data in this release.
A routed call, as the engine forwards it:
InvokeFunction. data is the Observed envelope (or the observed input / output, per the
observer’s deliver), metadata is the observer’s metadata sidecar, and caller is the engine
(worker_name: "iii-engine", with the observed function and invocation). An activation carries an
invocation_id and no action: the target’s SDK answers it with an InvocationResult like any
call. For auto and obs.emit activations the engine discards the result; the answer frees the
observer’s max_in_flight slot, and so does observers.activation_timeout_ms (30 s by default) for
an activation never answered. The observed input never contains the engine’s _caller_worker_id
stamp.
obs.call). The owner of an owner-delivered function sends an InvokeFunction
to the observer’s target with activation: { observer_id }, and gets the target’s result back as an
ordinary InvocationResult. The SDK sends no namespace: the engine routes the call to the
observer’s stored target and ignores a namespace if one is present. The engine checks, in order:
- The observer exists, else
OBSERVER_NOT_FOUND. - The sender is the current owner of the observed function, else
NOT_FUNCTION_OWNER. - The observed function has
delivery: owner, elseNOT_OWNER_DELIVERED. - The observer is
active, elseOBSERVER_NOT_ACTIVE. function_idequals the observer’s target, elseTARGET_MISMATCH.- Policy: depth (
LOOP_GUARD), expiry,max_fires, thenmax_in_flight(OVERFLOW; an owner delivery over the limit fails instead of queueing, because the owner waits for the result).
activation_id, and observe_depth + 1, and routes the
call. A failed check comes back as the InvocationResult.error of that call; a target that does not
answer within observers.activation_timeout_ms fails with TIMEOUT.
InvocationResult
Success:
ErrorBody.code values that appear in InvocationResult.error include invocation_failed (handler threw),
invocation_stopped (the owning worker disconnected mid-flight, so the engine cancels the in-flight
call and surfaces this code to the caller), function_not_found, function_not_invokable,
TIMEOUT (client-side timeout), FORBIDDEN (RBAC denial).
A function_not_found message names the namespace the lookup ran in, and lists the namespaces where
the id does exist: Function state::get not found in namespace default. It is registered in namespace(s): orders, analytics.
RegistrationRejected
namespace.
owner_worker_id identifies the worker that owns the identity. code specifies the identity field
and the severity. Each message contains only one identity field:
A function conflict has this format:
Function conflict behavior
Worker registration and function registration are separate operations. The engine can accept a worker and reject one of its functions. For a function ownership conflict, the engine:- Keeps the current function owner.
- Does not register the new handler.
- Sends
FUNCTION_NAMESPACE_CONFLICTto the new worker. - Keeps the new worker connection open.
- Continues to register and serve the new worker’s other functions.
FUNCTION_NAMESPACE_CONFLICT is a registration result. It is not an invocation result. A later
invocation of the same function id in the same namespace goes to the current owner. It does not go
to the worker whose registration was rejected.
A worker restarting against its own not-yet-cleaned connection is not a conflict: the engine treats
a connection that has begun tearing down as not live, so the restart reclaims its name immediately.
Trigger actions
InvokeFunction.action is tagged by type and lowercase-encoded on the wire:
Invocation lifecycle
For synchronous calls the engine assigns aninvocation_id, forwards the InvokeFunction to the
owning worker, and waits for the matching InvocationResult. For Void actions the engine forwards
without an invocation_id and never expects a reply. For Enqueue the engine hands the invocation
to the queue worker, which persists it and re-invokes the target function on a subscriber according
to the queue’s retry policy.
Observe messages
0.25 makes every function observable. An observer is whatiii.observe(source, target, options)
creates: a source function with its args and filters, a target function, and a policy. The
observing worker is the connection that sent Observe; the owner is the connection that
hosts the observed function. For the model, see Observers; for the
provider side, see Observable functions; for the filter
language, see Filters.
The wire examples on this page use
cron::on-tick, http::on-request, and
database::on-row-changed as illustrative source functions. The cron, http, and database workers
have not migrated in 0.25 and still publish trigger types.Wire rules
- Optional means absent. An optional field that is not set is left out, never sent as
null. Decoders acceptnullfor it anyway. InInvocationsObserveditems,input,output,error, andmetadatakeepnullapart from absent:"output": nullis a function that returnednull. - Enums are lowercase strings.
delivery:auto | owner.capture[]:input | output | error | caller | metadata.on:success | error | complete.deliver:envelope | input | output.state:pending | active | suspended | error | disposed.status:success | error. A decoder keeps a value it does not know instead of failing the frame. - Defaults. Absent
onissuccess, absentcaptureis["input", "output", "caller"], absentdeliverisenvelope, absentobserve.deliveryisauto, absentobserve.allowistrue, and absentobserve_depthis0(0is omitted when sending).capture: []is valid: the target then gets ids, status, and timings only. - Unknown values are refused, never defaulted. An
Observewith an unknownon,deliver, orcapture[]value getsObserveResultwith errorUNSUPPORTED(the message names the field). ARegisterFunctionwith an unknownobserve.deliverygetsRegistrationRejectedINVALID_OBSERVE_DESCRIPTOR. - Ids are strings. Observer ids are client-generated UUID v4 strings, stable across replays.
- Malformed frames. An
Observethe engine cannot decode but whoseidis a string getsObserveResultwith errorINVALID_ARGS. - Revisions.
revisioncounts adds and removes per(namespace, function_id)and grows by one on each. An SDK ignores anObserverAddedorObserverRemovedwhoserevisionis not greater than the one it holds on the same connection.FunctionRegisteredis always accepted and replaces the set (revisions restart after an engine restart). - Normal form. The
filtersthe engine pushes to an owner are the observer’s filters in normal form:{}matches every event, anything else is{"$and": [...]}at every level. Compile them as given. See Filters. - What owners see. An owner receives an observer’s
id,args({}when none), normalizedfilters,targetfunction id (its namespace is not disclosed),capture,on, andlegacywhen set. It never receivesdeliver, the observer’s metadata, its policy, or the observing worker.
Observe
Observing worker -> engine. Creates an observer owned by the sending connection, or replays it
idempotently (same id, same spec).
The minimal form names only the two functions:
on_observer_registration_function_id hook may rewrite or deny), args,
filters (including filter paths against the function’s request_format / response_format), the
per-connection and per-function limits, and that the target is not the observed function
(SELF_TARGET). A replay with the same id and a different spec fails with
OBSERVER_ID_CONFLICT.
Unobserve
Observing worker -> engine. Disposes an observer this connection owns.
ObserveResult
Engine -> observing worker. The reply to Observe (also to a replay): the observer’s current state.
state: error and the error. warnings lists filter paths the engine
could not verify against the function’s schemas (an open schema, a remote $ref, several
anyOf branches):
ObserverStateChanged
Engine -> observing worker. Every lifecycle transition. fires is always sent; reason explains
error, suspended, and disposed states.
FunctionRegistered
Engine -> owner (only to connections that declared observe.v1). The success ack RegisterFunction
never had, sent once per registration and again after a re-registration. It carries the function’s
full current observer set and whether the SDK may run the function in-process.
observersis sent even when empty.local_dispatchistruewhen the engine’sobservers.local_dispatchis on, the session has no RBAC middleware, and the connection may invoke the function itself. Only then may the SDK run its own calls to the function in-process.- The owner must ack every observer listed, each with this message’s
revision, including the ones it already held before a re-registration: a new registration starts a new owner relationship. Until it is acked an observer stayspending; afterobservers.owner_ack_timeout_ms(30 s by default) it fails withOWNER_TIMEOUT.
ObserverAdded
Engine -> owner (only to connections that declared observe.v1). One observer attached to a function
this connection hosts. It waits for an ObserverAck.
capture and on resolved. observer.legacy is present only for an
observer made by the legacy shim.
ObserverRemoved
Engine -> owner (only to connections that declared observe.v1). One observer detached: Unobserve,
disposal, the observing worker’s grace period elapsed, or a late rejection.
ObserverAck
Owner -> engine. Accepts one announced observer, echoing function_id, namespace, id, and the
revision of the message that announced it (ObserverAdded, or FunctionRegistered). The SDK sends
it after the owner’s onObserverAdded callback returns, so whatever the owner indexed there is in
place before the engine marks the observer active.
error, the ack rejects the observer, which turns error with that reason. The code is the
owner’s own (here INVALID_CRON_EXPRESSION), OBSERVE_REJECTED when the callback threw a plain
error, or OWNER_TIMEOUT:
namespaceis optional; absent means the sending connection’s namespace.- An ack whose
revisionis older than the announcement is ignored as stale. - Late rejection. An owner may send an ack with
errorfor an observer that is alreadyactive(for example after a configuration reload removed what it observed). The engine moves the observer toerrorand sendsObserverRemovedback to the owner. - The owner never changes an observer’s filters.
InvocationsObserved
Owner -> engine. Batched reports: matched local invocations of auto functions (the SDK evaluated the
filters), and obs.emit events of owner-delivered functions (the owner picked the observers, the SDK
applied their filters). SDKs flush at 256 items or after 5 ms, whichever comes first, and split a
flush to stay under the 16 MiB frame limit.
The engine checks the items one by one:
- The reporting connection must be the current owner of the function, else the item is dropped as
REPORTER_NOT_OWNER. - An item that does not decode, or whose
statusis neithersuccessnorerror, is dropped alone (counted asINVALID_REPORT), and the rest of the batch is applied. matchedids that are not active observers of that function are dropped asINACTIVE.- The engine does not re-evaluate filters on reports: the owner is authoritative for its own
function’s data.
onis not checked for owner-delivered functions.
OVERFLOW), the engine
stops reading from the reporting connection until that queue is back to half of
observers.max_queue_per_observer and the queues together are under half of their 64 MiB, the
observer leaves active, or 250 ms pass. A worker that
reports faster than the targets take is slowed by its own socket, and its SDK drops the excess from
its bounded report buffer (BUFFER_FULL), so the engine does not spend its CPU decoding reports it
would drop. Everything else the worker sends waits behind the report meanwhile, so the holds are
paced: after holding a connection, the engine reads it for at least a quarter as long before it may
hold it again, and drops the items that overflow meanwhile (OVERFLOW). A connection is held at
most 250 ms at a time and at most four fifths of the time, however long its reports keep
overflowing. The engine does not wait for an observer whose target the reporting connection hosts:
the answers that would free the queue come in on that connection.
LocalInvocationStats
Owner -> engine. Every 10 s, only when non-zero: per-function counts of local invocations (which feed
the engine’s invocation metrics) and SDK-side report drops.
functions[].errors and functions[].duration_ms_sum default to 0; both arrays default to empty.
The engine ignores entries for functions the connection does not own. dropped_reports[].reason is
BUFFER_FULL or TOO_LARGE; any other reason is counted as OTHER.
Legacy shim
A provider can move from a trigger type to a function without breaking workers that still sendRegisterTrigger. The function declares observe.legacy_trigger_type with the trigger type it
replaces. When a RegisterTrigger names a type with no live old-style provider, and a function
declares that type (looked up in the trigger’s trigger_namespace only when it names one, otherwise
in the registering connection’s namespace, then default), the engine:
-
Creates an observer on that function, owned by the registering connection: the trigger id becomes
the observer id, with no args or filters,
deliver: input(so the target receives what a trigger handler received), the trigger’smetadataas the metadata sidecar, andlegacy: { trigger_type, config }. -
Sends
ObserverAddedto the owner withobserver.legacy: -
The owner validates
legacy.configas its trigger handler did, reads it as the observer’s args, and acks or rejects it like any observer. The engine skipsargs_formatvalidation for it. -
The registrant gets one
TriggerRegistrationResultwith the outcome: a success once the owner acked (SDKs ignore it), or the owner’serrorif it rejected. -
UnregisterTrigger(orengine::unregister_trigger) disposes the observer.
RegisterTrigger
with the same id replaces the observer, as it replaces a trigger. Only an owner that declared
observe.v1 stands in for a trigger type; legacy_trigger_type on any other owner is ignored.
Bindings stored while no provider existed convert when a stand-in registers. When the owner leaves,
its legacy observers wait and are replayed like any other observer.
Error codes
ObserveResult.error.code and ObserverStateChanged.reason.code on a rejected observer:
Owner delivery (
obs.call) fails with OBSERVER_NOT_FOUND, NOT_FUNCTION_OWNER,
NOT_OWNER_DELIVERED, OBSERVER_NOT_ACTIVE, TARGET_MISMATCH, LOOP_GUARD, OVERFLOW, TIMEOUT,
or the target’s own error. SDK-side, obs.call fails with FILTERS_NOT_MATCHED (the observer’s
filters reject the payload; nothing is sent), NOT_OWNER_DELIVERED (obs.emit or obs.call on an
auto function), or OBSERVER_NOT_ACTIVE.
State reasons (ObserverStateChanged.reason.code):
Drop reasons, counted per observed function in
iii.observer.dropped.total (never silent; the
metric is updated once a second):
Namespaces
A namespace is a routing value carried with a function id. It is not part of the function name. For example,state::get has the same id in every namespace. Registries are keyed by
(namespace, function_id) and (namespace, worker_name), so the same id or worker name may appear
once per namespace.
A worker declares its namespace on the engine::workers::register call
(RegisterWorkerInput.namespace). A
connection that declares none lands in default.
Resolution
Invocation routing is strict. It never falls back to another namespace:
The SDKs fill the field from the worker namespace when the caller sets none on the invocation, so the
frame carries the worker’s own namespace, or
default when the worker declared none.
A miss returns function_not_found naming the namespaces where the id does exist.
Introspection resolution is deliberately looser, so that an engine whose every worker is namespaced
can still answer questions about itself. For engine::functions::info and engine::workers::info
with no explicit namespace: a default entry wins; otherwise an id or name unique to one
non-default namespace resolves; an id or name present in several non-default namespaces at once is
reported as an ambiguity naming the candidates, never resolved by guessing. Passing an explicit
namespace restores strict resolution.
Reserved ids
engine::* is reserved for engine infrastructure, which is registered in default. A custom worker
that tries to register an engine::* function id in another namespace has that registration
refused.
The queue worker supplied with the engine registers its engine::queue::* functions in default.
The reserved-id check does not reject these functions.
Wire compatibility
Every namespace field is optional and omitted when unset. A worker built against an older SDK sends no namespace, lands indefault, and behaves exactly as before. An explicit null is not the same
as an absent field: the engine rejects it, as it rejects any non-string value.
Engine discovery functions
The engine registers a set of functions under theengine::* prefix for introspection
and worker lifecycle. Defined in
engine/src/workers/engine_fn/mod.rs:
Every row returned by
engine::functions::list, engine::functions::info, engine::workers::list,
and engine::workers::info carries a namespace field naming the registry key under which the entry
is registered. It distinguishes two rows that share a function_id or a worker name. Neither
list function takes a namespace filter; both return every namespace.
Engine discovery triggers
In 0.25 these trigger types keep firing, and the engine also hosts an event-source function for each.
Observe the function instead of registering a trigger, with
deliver: "input" (the events have no
output):
Both live in
default. Calling them directly fails with FORBIDDEN: their events are the engine’s to
emit. See Engine-hosted event sources.
Engine-collected metrics
These metrics are emitted by the engine regardless of which language SDK a worker uses. Names and units come fromengine/src/workers/observability/metrics.rs.
Invocations
Since 0.25 a worker runs calls to its own functions in-process (local dispatch), so the engine does not
route them. SDKs report their counts in
LocalInvocationStats, and the engine adds them to
iii.invocations.total with the labels function_id, status (ok or error), and
dispatch="local". Local invocations have no engine-side iii.invocation.duration sample.