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 call 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 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.
Channel sockets
Each end of a channel fromengine::channels::create opens its own WebSocket on the worker
listener: /ws/channels/{channel_id}?key={access_key}&dir=read for the reader, dir=write for the
writer. The engine forwards the writer’s text and binary frames to the reader in order.
- Clean end. The writer sends a close frame with no status code or with code
1000. The engine classifies the end when that frame arrives, so a writer that drops the socket right after sending it still ends cleanly. The reader gets the queued data, then a close frame with no status code (clients see1005). - Abort. Anything else on an attached writer socket: a close frame with any other code (
1001,1011,4000, and so on), or a connection that ends without a close frame (TCP reset, receive error). A browser client can only send1000or3000-4999, so one that aborts on purpose must use a code from that upper range; a page that closes mid write sends1001, which is also an abort. The reader gets the data queued before the abort, then a close frame with code1011and a short reason, such aschannel writer aborted: connection lost before close. A reader waiting on a channel whose owner disconnected before any writer attached gets the same1011close. - Reader end. When the reader closes or disconnects, the engine removes the channel and closes or drops an attached writer’s socket.
1000 or no status code as the end of the stream, and any other
close, or a connection that ends without a close frame, as an error. A reader that closed its own
socket treats the end that follows as normal. After the engine sends a close frame to a reader, it
waits up to 1 s for the close reply before it drops the socket.
Feature negotiation
Observers, host delivery, and local dispatch are negotiated per connection, so SDKs and engines from different releases work together.- Engine to worker.
WorkerRegisteredcarriesfeatures. This engine sends["observe.v1", "observe.v2", "observe.capture", "local-dispatch.v1"], in that order. A 0.26 release candidate sends["observe.v1", "observe.v2", "local-dispatch.v1"], a 0.25 engine sends["observe.v1", "local-dispatch.v1"], and engines before 0.25 omit the field. - Worker to engine. The worker lists the features it supports in the
featuresarray of itsengine::workers::registerpayload. Every 0.26 SDK declaresobserve.v1,observe.v2,observe.capture, andlocal-dispatch.v1(a 0.26 release candidate SDK declares noobserve.capture). The Node, Python, Rust, and Go SDKs also declarelarge-messages.v1, which the engine never advertises (see the rules below). A pre-0.25 SDK sends none.
Rules:
- 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, and reads
InvocationResult.matchedandInvocationResult.deliveredonly from connections that declaredobserve.v2. 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. 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 }. - Against an engine that did not advertise
observe.v2, an SDK leaves each function that declaresargs_format,private: true, orobservable: false, or that sets anonObservationMatchoronObservationDeliverhook, unregistered, and logsUNSUPPORTEDonce for it. It holds that registration until the current connection’sWorkerRegisteredlistsobserve.v2, and decides again on every reconnect, including when it replays registrations. Every other function,call, andobservework: the SDK reports matches of local calls only, sends nomatchedand nodelivered, and sendsoutputwhere it would setoutput_is_input, and the engine evaluates every active observer of a routed call. observe.captureis negotiated on its own, not implied byobserve.v2. The engine sends every observer view (FunctionRegistered,ObserverAdded) withfires_on. To a host that did not declareobserve.capture(a 0.25 SDK, or a 0.26 release candidate SDK that declaresobserve.v2), it also sends the legacyonthose outcomes imply, which is how such a host decides the outcomes. The engine reads each host’s features once perFunctionRegistered, not once per observer.- An
Observefrom a connection that did not declareobserve.capturegets the 0.25 semantics, whether or not it carriesonordeliver(see Legacyonanddeliver). - Against an engine that did not advertise
observe.capture(0.25, or a 0.26 release candidate), a 0.26 SDK sends eachObservewith the legacyonitscaptureimplies (errorfor a capture that fires on errors only,completefor one that fires on both, none for successes), on the first send and on every replay, and never sendsonto an engine that advertisedobserve.capture. It reads observer views withoutfires_onthe 0.25 way: the outcomes come fromon(absent means success), not fromcapture. It logs a deprecation warning once per client: the connected engine does not supportobserve.capture, legacyonsemantics are deprecated and will be removed in a future version, and the engine must be upgraded. Support for engines withoutobserve.capturewill be removed in a future release. - 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. AnObserveregistered in that window gets the 0.25 semantics.
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
A field that is not set is left out, and so are
private: false and observable: true. The engine
leaves out request_format and response_format when it sends a registration that has none. The
SDK’s observer hooks stay in the worker and never travel. See
Observable functions for the provider side.
An event source sets the v2 fields. This cron::on-tick validates its observers’ args, may be
called only by its own worker, and returns the tick it is called with, so its response_format is
its request_format:
observe.v1 receives
FunctionRegistered.
Registrations from older SDKs
A 0.25 SDK describes observation with anobserve object on RegisterFunction:
{ delivery?, args_format?, legacy_trigger_type?, allow? }. The engine decodes it from any
connection:
delivery: "auto"and an absentdeliveryare accepted.delivery: "owner", or a value the engine does not know, is refused withRegistrationRejectedUNSUPPORTED, and the function is not registered.allow: falseregisters the function asobservable: false; an absentallowistrue.args_formatandlegacy_trigger_typeare decoded and ignored.
InvokeFunction
invocation_id is omitted on Void invocations from a caller (the caller has no result channel to
reply on). metadata is any JSON value delivered to the receiving function as a distinct argument
alongside the payload: the call’s metadata sidecar. null is the same as no metadata: the engine
reads it as absent, and every SDK leaves the field out and hands its handler no metadata. On an
activation it is the observer’s metadata, which lets a target shared by many observers recover
which one fired and with what context.
traceparent and baggage contain W3C trace context. action is the routing flag (see
Invocation 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.
Private functions. The engine accepts an InvokeFunction of a function registered with
private: true only from the connection that hosts it. Any other connection, and the engine itself
(RBAC hooks, middleware), gets an InvocationResult error FORBIDDEN. The hosting connection’s own
InvokeFunction of its private function skips the session’s RBAC middleware.
Observe fields
Three optional fields carry causality and activations. 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. To an observe.v2 host, every routed call carries an
invocation_id, void calls included, so the host answers each one (see
Who delivers):
InvokeFunction. data is the Observed payload (the top-level fields, plus what the
observer’s capture names that applies to the outcome), 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. The engine discards that result for the observed side, and delivers the call to the
target’s own observers as for any routed answer (Routed answers). 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.
Calls through an observer
A function’sonObservationDeliver hook can run an observer’s target itself with
iii.call({ observer, payload }), for example to route an HTTP request to one route’s handler and
return that handler’s response. The SDK sends an InvokeFunction whose function_id is the
observer’s target and whose activation carries observer_id only, without namespace or
metadata. The engine routes the call to the observer’s stored target and ignores a namespace or
a metadata if one is present: the target’s metadata is the observer’s. Every SDK refuses a call
through an observer that names metadata before sending it. The engine checks, in order:
- The observer exists, else
OBSERVER_NOT_FOUND. - The sender hosts the observed function, else
NOT_FUNCTION_OWNER. - The observer is
active, elseOBSERVER_NOT_ACTIVE. - Policy: depth (
LOOP_GUARD), expiry,max_fires, thenmax_in_flight. A call over the observer’smax_in_flightfails at once withOVERFLOW.
activation_id, and observe_depth + 1, and routes the
call. The call runs as an activation of the observer: the target runs under the session of the
worker that created the observer, and the payload goes as given less a top-level
_caller_worker_id, so the host’s identity and grants authorize nothing the target does. It is that
worker’s call: a private target takes it only when that worker hosts the target. The host is the
call’s caller. The deliver hook applies the observer’s capture and filters with obs.matches(call)
before it calls.
caller is the http::on-request invocation the deliver hook runs in. A request/response call gets
a failed check back as the InvocationResult.error of that call, and a target that does not answer
within observers.activation_timeout_ms fails with TIMEOUT:
action: { "type": "void" }, no invocation_id) gets
no answer. The engine drops a refused one, counts it in iii.observer.dropped.total with its code
as the reason, and logs it at debug:
obs.accepted()
resolves, as a call through that observer, as above. See
Send an observer its initial state.
InvocationResult
Success:
"result": null is a function that returned null. result is absent when the function returned
nothing, and on an answer that carries error.
ErrorBody.code values that appear in InvocationResult.error include invocation_failed (handler
threw), invocation_stopped (the hosting 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), and FORBIDDEN (RBAC denial, or a call
to a private function from a connection that does not host it). A call through an observer can also
fail with the codes in Calls through an observer.
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.
Routed answers
Anobserve.v2 host’s answer to a routed call says how the call is delivered. The engine evaluates
the observers’ capture and filters itself, with the input it kept when it forwarded the call, the
answer’s result or error, the caller it forwarded the call with, and the call’s metadata. The
host runs what is worker code, the function’s match and deliver hooks, and its answer carries one of
three things:
The answer for a function with a match hook:
- An answer for a function without hooks is the plain success or failure above: an absent
matchedleaves every observer to the engine. A function with a match hook and no deliver hook always answers withmatched,[]included. A host sends at most one ofmatchedanddelivered; with both,deliveredwins. - An answer too large to send is answered with the error
payload_too_large. The error is not the handler’s outcome, so it activates no observer: it carriesdelivered: truewhen the deliver hook delivered the call, andmatched: []otherwise, whatever the function’s hooks. A host whose engine lacksobserve.v2sends the bare error. The caller getspayload_too_largeeither way. matchedmay ride an answer that carrieserror: iii’s delivery rethrows the handler’s error, and the payload’sstatusanderrorcome from the answer.- The host names the observers of a routed call on its answer only. An
InvocationsObserveditem reports a local call. - The engine reads
matchedanddeliveredonly fromobserve.v2connections, on every routed answer, a target’s answer to an activation or to a call through an observer included, and strips both before it forwards the result to the caller. - Only the function’s host answers for its observers. An answer from an
observe.v2connection that does not host the function (a caller answering its own call by itsinvocation_id, say) activates nothing and is not evaluated, whatever it carries: each observer itsmatchednames counts aREPORTER_NOT_OWNERdrop, and withoutmatched(neither field, ordelivered) every active observer of the function does. The caller gets the answer’s result. - A routed call whose answer never comes (its caller left, the host went away) activates nothing:
each of the function’s observers counts a
CANCELLEDdrop. - The payload’s
inputis the input the engine routed, and itsoutputisresult. Delivery costs no frame beyond the answer. The engine matches the filters through an index of their$eqand$inconditions, and evaluates every filter where the lookups would spare little, so a call costs at most about the evaluation of every filter plus one lookup per indexed path, and about one lookup when many observers filter on their own value of one path (see Filters).
RegistrationRejected
namespace, owner_worker_id identifies the worker
that owns the identity; on every other rejection it is "". code specifies the cause and the
severity:
A function id refused because it is reserved also arrives as
FUNCTION_NAMESPACE_CONFLICT. No worker owns it, so owner_worker_id is engine (reserved prefix).
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. A function id has one host per namespace, so a function’s observer hooks run once per observer, in that host, however many instances of the worker run. For a function ownership conflict, the engine:- Keeps the current function host.
- 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 host. It does not go to
the worker whose registration was rejected.
The SDK does not register a refused function again. When the host disconnects, the function’s
observers go pending; the worker that registers the function next receives them in
FunctionRegistered, runs its hooks for each, and they become active again.
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.
Invocation actions
InvokeFunction.action is tagged by type and lowercase-encoded on the wire. The SDKs build it
with CallAction:
Invocation lifecycle
For synchronous calls the engine assigns aninvocation_id, forwards the InvokeFunction to the
hosting worker, and waits for the matching InvocationResult. For Void actions the caller gets no
reply: the engine forwards the call without an invocation_id, except to an observe.v2 host,
which gets one and answers with the observers its delivery matched; the engine activates them and
drops the result. For Enqueue the engine hands the invocation to the queue worker, which persists
it and invokes the target function according to the queue’s retry policy.
Observe messages
Every function is observable. An observer is whatiii.observe(source, target, options)
creates: a function with its args and filters, a target function, and a policy. When the
function runs, the target runs. The observing worker is the connection that sent Observe; the
host is the connection that hosts the observed function. For the model, see
Observers; for the host side, see
Observable functions; for the filter language, see
Filters.
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, while a missingoutputwas not captured, does not exist, or equalsinput("output_is_input": true). - Enums are lowercase strings.
capture[]:input | output | error | caller | metadata.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
captureis["input", "output", "caller"], and absentobserve_depthis0. A field at its default is left out when sending:observe_depth: 0,private: falseandobservable: trueonRegisterFunction,observed: []onInvocationResult, andoutput_is_input: falseonInvocationsObserveditems. capturedecides the outcomes.inputandoutputfire the target on success,erroron error;callerandmetadataonly add to the payload. A non-emptycapturethat names none ofinput,output, anderrorgetsObserveResultwith errorINVALID_ARGS.capture: []fires on success, and the target then gets ids, status, and timings only. A host applies the same rule to the calls it evaluates, reading the outcomes from the view’sfires_on. These rules apply to anObservefrom a connection that declaredobserve.captureand that carries neitheronnordeliver; every otherObservefollows Legacyonanddeliver.- Engine-owned observers capture
inputonly. An observer the engine creates for itself and that hands its target the bare input (the configuration and stream bridges, telemetry onqueue::on-message, observability onade::ui::asset) keeps and announcescapture: ["input"], so a host reports nooutput,caller, ormetadatafor it. - Unknown values are refused, never defaulted. An
Observewith an unknowncapture[]value getsObserveResultwith errorUNSUPPORTED(the message names the field). ARegisterFunctionwhoseargs_formatis not a valid JSON Schema getsRegistrationRejectedINVALID_ARGS_FORMAT. - Ids are strings. Observer ids are client-generated UUID v4 strings, stable across replays.
- Malformed frames. The engine reads
caller,observe_depth, andactivationonInvokeFunction, andmatchedanddeliveredonInvocationResult, leniently: a badcaller,observe_depth, oractivationcounts as absent, amatchedthat is neither an array nornullcounts as[](a non-string id in it is skipped),deliveredcounts only when it istrue, and the rest of the frame is handled. AnObservethe engine cannot decode but whoseidis a string getsObserveResultwith errorINVALID_ARGS. - Revisions.
revisioncounts announcements per(namespace, function_id)and grows by one with each:FunctionRegistered, everyObserverAdded, and everyObserverRemoved. 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 a host are the observer’s filters in normal form:{}matches every call, anything else is{"$and": [...]}at every level. Compile them as given. See Filters. - What hosts see. A host receives an observer’s
id,args({}when none), normalizedfilters,targetfunction id (its namespace is not disclosed),capture, andfires_on: the outcomes that fire the observer,["success"],["error"], or["success", "error"]. A host withoutobserve.capturealso receiveson("error"or"complete"; absent means success). It never receives the observer’s metadata, its policy, or the observing worker.capturealone does not tell the outcomes: a translated legacy observer can capture["error"]and fire on success.
Who delivers
The connection that hosts a function runs the function’s hooks when it declaredobserve.v2. Its
SDK runs the handler, then matches observers to the call: every active observer by default, or those
the function’s onObservationMatch hook selects by predicate or by keys. iii’s delivery then
selects the matching observers whose capture and filters match the call. A function’s
onObservationDeliver hook takes delivery over: it receives the matching observers and calls
through them itself. Where the filters run depends on the call:
- Local call. The host called its own function, and the engine never sees the call. The host
evaluates capture and filters and sends the observers delivered in a batched
InvocationsObservedreport. Nothing is sent when nothing matches, or when the deliver hook delivered the call. - Routed call. The engine routed the call to the host with an
invocation_id, and holds its input, caller, and metadata. The host’s answer names the observers the match hook matched, or says that the deliver hook delivered the call, and the engine evaluates capture and filters (Routed answers). - Through an observer. The host runs one observer’s target itself with an
InvokeFunctionthat carriesactivation.observer_id(see Calls through an observer).
observe.v2, of a
call to an engine-hosted function, and of a call to a function registered with invocation
(HTTP-invoked: the engine calls the endpoint, so no connection answers).
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. It is the frame
iii.observe('orders::archive', 'search::reindex-order') sends:
on_observer_registration_function_id hook may rewrite or deny), that the function is observable
(NOT_OBSERVABLE), args, filters (including filter paths against the function’s request_format /
response_format), the per-connection and per-function limits, that the target is not the observed
function (SELF_TARGET), and that the target is not a private function (FORBIDDEN).
An Observe whose id an observer already holds is a replay when it comes from the observing
connection with the same spec: the engine answers with the observer’s current state. The replay of
an observer in error is refused with that error: the engine disposes the observer as it answers,
sends nothing more about it, and holds nothing under the id. A different spec, or another
connection, fails with OBSERVER_ID_CONFLICT, except for a worker back on a new connection. When
the observer is suspended because its observing worker is away, an Observe of its id from a
connection under the same worker name in the same namespace (as declared on
engine::workers::register) replaces it. Any connection can declare any name, so the two
connections must also be the same principal: both on ports without rbac, or both on RBAC ports
where the auth function gave them the same context, one that is not empty. A session with no
context replaces nothing, and a connection on a port without rbac and an RBAC session never
replace each other’s observers. A restarted worker re-observes under the ids it used before this
way, and a queue consumer keyed by observer id keeps its backlog. The engine runs every check above
first, and replaces the suspended observer when it creates the new one: it disposes the suspended
observer with OBSERVER_DISCONNECTED, sends its host nothing more about it (the host got
ObserverRemoved when the worker left), and the host gets an ObserverAdded for the new observer,
as for any new id. An Observe the engine refuses leaves the suspended observer holding its id.
The earlier connection, reattaching, has its replay of that observer answered with disposed.
The takeover needs a worker name that stays the same across restarts: III_WORKER_NAME, which a
supervisor sets, or the SDK’s worker name option. The SDKs default the name to hostname:pid (the
browser SDK to a random name), which changes when the process restarts, so a restarted worker under
the default name gets OBSERVER_ID_CONFLICT until the grace period ends.
Legacy on and deliver
0.25 SDKs and 0.26 release candidate SDKs can send source.on and source.deliver. Both are
deprecated, and support will be removed in a future release. The engine does not refuse them: it
translates them when it registers the observer, on the first Observe and on every replay, to the
0.25 behavior, and logs one warn per registration that names the observer id, the fields, and the
source.capture to use instead.
- Outcomes come from
onalone. Absent orsuccessfires the target after a successful call,errorafter a failed one, andcompleteafter both.capturedoes not change the outcomes, and the rule that acapturemust nameinput,output, orerrordoes not apply:capture: ["caller"]is accepted and fires on success. - The payload carries the
capturelist exactly. An absentcaptureis["input", "output", "caller"]. deliverpicks the payload. Absent orenvelope: theObservedpayload.input: the call’s input alone,nullwhen the call has none.output: the call’s output alone,nullwhen it has none, so the target getsnullafter every failed call.- Invalid values are refused. An
onordelivervalue the engine does not know, or one that is not a string, getsObserveResultwith errorINVALID_ARGS.nullcounts as absent. - Replays are stable. The translation is part of the observer’s spec: a replay with the same
fields is idempotent, and one that changes them fails with
OBSERVER_ID_CONFLICT. - Connections without
observe.capture. AnObservefrom a connection that did not declareobserve.capture(a 0.25 SDK, or a 0.26 release candidate SDK) gets these semantics even when it carries neitheronnordeliver. Its outcomes come fromon, socapture: ["error"]withoutonfires on success, as it did on 0.25. When the source carries neither field, the onewarnper registration says that the worker does not declareobserve.captureand names thesource.captureto use after upgrading the SDK.
Observed payload omits a field it does
not have instead of sending it as null. On 0.25, an observer that captured output got
"output": null after a successful call that reported no output; on 0.26 that payload has no
output.
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.
Answer order and observer ids
The engine reads one connection’s messages in order and sends that connection its observe answers (ObserveResult, ObserverStateChanged) in the order it makes them. It answers every Observe
with exactly one ObserveResult, and sends ObserveResult for nothing else. An id therefore
names one observer at a time on the wire, and an SDK can observe again under an id as soon as it has
unobserved the earlier observer of that id, or received its disposed. It tells the two observers’
answers apart by order:
- It keeps, per id, the observers whose
Observeit sent on the current connection, oldest first. A disposed one stays listed until itsdisposedarrives, a newer observer of the id has itsObserveResult, or the connection closes. - An
ObserveResultfor the id answers the oldest listed observer that has none yet. - An
ObserverStateChangedfor the id is about the newest listed observer that has itsObserveResult. One that arrives before any has it is dropped: a reattached connection hears its observers resume before its replayedObserves are answered, and each replay’sObserveResultcarries the current state. - On a new connection, the
Unobserves queued while disconnected go out before any replayedObserve, so the engine disposes the earlier observer before it reads the new one’sObserve. - An
ObserveResultthat refuses theObserve(state: "error") is that observer’s last answer, and the observer leaves the list when it arrives. Disposing a refused observer sends nothing. A dispose whoseUnobservewent out before the refusal arrived ends when the refusal arrives.
disposed for the earlier observer, sent before it read the new Observe, reaches the
earlier observer’s handle, and the new handle gets its own answers only. The engine stores nothing
for a refused Observe, and ignores an Unobserve of an id that none of the connection’s observers
holds: nothing answers it, and another connection’s observer of that id stays as it was. An SDK
refuses an observe whose id one of its live handles holds, with OBSERVER_ID_CONFLICT and nothing
sent.
An observer the engine disposes (max_fires, expiry, an admin) frees its id on a connection that
heard its disposed: one that had the observer’s ObserveResult before the ObserverStateChanged
that disposed it. That connection may observe under the id again at once. On a connection that
reattached, the engine answers the first Observe of that id with disposed and its reason when
the disposal came at most observers.observer_grace_ms before the earlier connection closed, or
while it was away: it reads the Observe as a replay whose disposed was lost with the earlier
connection, and does not create the observer again with new counters. A Reattach later than the
grace period gets the same answer, within observers.disposed_retention_ms. After that answer, or
an Unobserve of the id from the connection, the id is free there.
An SDK uses that free id when the engine disposed the observer because its worker stayed away past
observers.observer_grace_ms. When the answer to a replayed Observe, or an
ObserverStateChanged, is disposed with OBSERVER_DISCONNECTED and the observer has neither
max_fires nor expires_in_ms, the SDK sends a new Observe with the same id, source, target,
metadata and options on the same connection, and the engine creates a new observer with its counters
at 0. The handle keeps its id, goes to pending without reporting disposed, and takes the new
ObserveResult. The SDK does not observe again an observer with either limit (it becomes
disposed, so the limits hold across the absence), one whose dispose has started, or one it already
observed again on this connection (a second such disposed disposes it). This is SDK behavior: the
engine’s answers are the ones above.
FunctionRegistered
Engine -> host (only to connections that declared observe.v1). The success ack of
RegisterFunction, 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. It lists the function’s observers except those whose observing worker is away (suspendedwithOBSERVER_DISCONNECTED): each of those comes in its ownObserverAddedwhen the worker reattaches.local_dispatchistruewhen the engine’sobservers.local_dispatchis on, the connection may invoke the function itself, and the session has no RBAC middleware or hosts the function privately. Only then may the SDK run its own calls to the function in-process.- The host 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 host relationship. The message replaces the set of observers the host holds for the function. An entry already held with the sameargsis re-acked without running hooks; a new entry runsonObserverAddedand is acked once it resolves; an entry whoseargschanged counts as removed, then added; a held observer the message does not list is removed. A new host that takes over the function holds none of its observers, so it runsonObserverAddedfor each entry and acks it the same way. Until it is acked an observer stayspending. When it is not acked withinobservers.owner_ack_timeout_ms(30 s by default) of the host moving on to it (the host runs a function’s hooks one at a time, in announcement order), it fails withOWNER_TIMEOUT.
ObserverAdded
Engine -> host (only to connections that declared observe.v1). One observer attached to a function
this connection hosts: a new observer, or one whose observing worker reattached within
observers.observer_grace_ms (in the order the worker created its observers). It waits for an
ObserverAck.
capture resolved: an observer that named none gets the default. Every view
carries fires_on; a view sent to a host without observe.capture also carries the legacy on
when the observer fires on errors ("error") or on both outcomes ("complete"). A view from an
engine without observe.capture has no fires_on, and a 0.26 SDK then reads the outcomes from
on.
ObserverRemoved
Engine -> host (only to connections that declared observe.v1). One observer detached: Unobserve,
disposal, its observing worker disconnecting, or a late rejection.
ObserverAck
Host -> 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 once every onObserverAdded hook that applies to the observer has resolved (the worker’s
hook, then the function’s), so whatever the hooks set up 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
one the hook threw with ObserveRejection (here INVALID_CRON_EXPRESSION), OBSERVE_REJECTED when
the hook threw any other error, or OWNER_TIMEOUT for a hook that ran past 30 s:
namespaceis optional; absent means the sending connection’s namespace.- An ack whose
revisionis older than the announcement is ignored as stale. - Late rejection. A host may send an ack with
errorfor an observer that is alreadyactive(obs.reject(code, message), for example after a configuration reload removed what it observed). The engine moves the observer toerrorand sendsObserverRemovedback to the host. - The host never changes an observer’s filters.
InvocationsObserved
Host -> engine. Batched reports of the host’s local calls: calls of its own functions that it made
itself. 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 host the function, else the item is dropped as
REPORTER_NOT_OWNER. - An item that does not decode, whose
statusis neithersuccessnorerror, or whoseinvocation_idis a routed call the engine forwarded to that connection, 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 evaluate filters on reports: the host is authoritative for its own function’s calls.
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
Host -> 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, 0 included; a decoder
reads an absent one as 0, and an absent array as empty. The engine ignores entries for functions
the connection does not host. dropped_reports[].reason is BUFFER_FULL or TOO_LARGE; any other
reason is counted as OTHER.
Error codes
ObserveResult.error.code and ObserverStateChanged.reason.code on a rejected observer:
A call through an observer fails with
OBSERVER_NOT_FOUND, NOT_FUNCTION_OWNER,
OBSERVER_NOT_ACTIVE, LOOP_GUARD, OVERFLOW, TIMEOUT, or the target’s own error.
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.
sandbox::* is reserved in default for the engine’s sandbox router. A sandbox adapter registers
its own <adapter>::* ids. A custom worker in default that tries to register a sandbox::*
function id has that registration refused.
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-hosted sources
The engine hosts event sources as functions in thedefault namespace. Each is a private function
whose handler returns its event, so a call’s input and output are both the event. They take no
args: observe them with filters on the event. Only the engine calls them; a call from a worker fails
with FORBIDDEN.
A call of an engine-hosted source has
caller { worker_name: "iii-engine" }, so a payload that
captures caller carries it and a filter on caller.worker_name sees iii-engine. The
activation’s own caller is the engine with the source’s function_id and invocation_id.
When an observer names no namespace, the SDK resolves engine::* and configuration::* in
default and every other function in the observing worker’s namespace. A worker in another
namespace names namespace: "default" to observe observability::* or stream::*. 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.