iii-observability
v0.24.5-alpha.3OpenTelemetry-based traces, metrics, logs, alerts, and sampling.
Install
iii trigger compose::add worker=iii-observability@0.24.5-alpha.3Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
iii-observability
Full OpenTelemetry observability for III Engine: distributed tracing, structured logs, performance metrics, alert rules, and trace sampling — all queryable via built-in functions.
iii-observability is injected by the engine. Do not declare it in config.yaml,
engine.workers, or project containers:. Configure its registered runtime configuration entry
through configuration::set.
Skills
Install the iii-observability agent skill for Claude Code, Cursor, and 30+ other agents:
npx skills add iii-hq/iii --full-depth --skill iii-observabilitySample Configuration
{
"function_id": "configuration::set",
"payload": {
"id": "iii-observability",
"value": {
"enabled": true,
"service_name": "my-service",
"service_version": "1.0.0",
"exporter": "memory",
"metrics_enabled": true,
"logs_enabled": true,
"memory_max_spans": 1000,
"sampling_ratio": 1.0,
"alerts": [
{
"name": "high-error-rate",
"metric": "iii.invocations.error",
"threshold": 10,
"operator": "greaterthan",
"window_seconds": 60,
"action": { "type": "log" }
}
]
}
}
}Configure
The full configuration surface is registered with the builtin configuration
worker under the id iii-observability. The stored entry is the runtime
source of truth. Change it through the console or configuration::set { "id": "iii-observability", "value": { ... } }; the worker is internal and
cannot be configured as an engine worker declaration.
With the default file-backed adapter the entry persists at
./config/iii-observability.yaml and is read again at every
engine start — before logging/tracing init — so even restart-tier fields
edited at runtime apply on the next start. ${VAR:default} placeholders work
in string fields and are expanded on read.
Values are validated against the JSON schema at configuration::set time
(unknown fields rejected, ratios bounded to 0..=1, counts ≥ 1). Two
caveats:
- Alert
operatorvalues use the canonical schema names (greaterthan,lessthan, ...). - After a schema tightening, a previously-stored out-of-range value makes
the boot-time schema refresh fail with
SCHEMA_INVALID(warn-and-continue); reads still work and out-of-range values are clamped on read.
Hot Reload
configuration:updated events are applied per field tier:
| Tier | Fields | Effect |
|---|---|---|
| Live | logs_console_output, logs_sampling_ratio, logs_enabled (ingest gate), enabled (ingest gate) |
Immediate — read per use |
| Limits | memory_max_spans, logs_max_count, metrics_max_count, metrics_retention_seconds |
Immediate — enforced on the next insert / 60s sweep |
| Swap | sampling_ratio, sampling.*, alerts, collapse_spans, level |
Immediate — compiled artifact rebuilt and swapped (alert states of surviving rules keep cooldown/firing continuity) |
| Task rebuild | logs_exporter, logs_batch_size, logs_flush_interval_ms, logs_retention_seconds, and logs_enabled on a false→true transition |
The background task restarts with the new settings. A logs_enabled false→true toggle revives the log store and respawns the log-trigger subscriber, OTLP logs exporter, and retention task — so the log trigger fan-out and OTLP log export reactivate without an engine restart |
| Restart-only | exporter, endpoint (trace and logs exporters), service_name/service_version/service_namespace (trace resource and logs exporter identity), format, metrics_enabled, metrics_exporter, enabled (pipeline construction) |
Logged as a warning; applied at the next engine start via the persisted entry. endpoint/service_name/service_version are restart-tier for all signals so logs and traces always move to the new collector/identity together, never split mid-edit |
Known limitation: an engine config-file reload that destroys and recreates this worker shuts down the OTLP trace/metric providers without rebuilding them (they are process-global set-once state) — OTLP export then requires an engine restart. Memory-backed stores are unaffected.
Configuration
| Field | Type | Description |
|---|---|---|
enabled |
boolean | Whether OpenTelemetry tracing export is enabled. Defaults to false. Env: OTEL_ENABLED. |
service_name |
string | Service name in traces and metrics. Defaults to "iii". Env: OTEL_SERVICE_NAME. |
service_version |
string | Service version (service.version OTEL attribute). Env: SERVICE_VERSION. |
service_namespace |
string | Service namespace (service.namespace OTEL attribute). Env: SERVICE_NAMESPACE. |
exporter |
string | Trace exporter: memory, otlp, or both. Use both when traces should remain queryable in iii while also being exported. Defaults to otlp. Env: OTEL_EXPORTER_TYPE. |
endpoint |
string | OTLP collector base endpoint. Defaults to "http://localhost:4317". Env: OTEL_EXPORTER_OTLP_ENDPOINT. |
sampling_ratio |
number | Global trace sampling ratio (0.0–1.0), applied to engine spans and worker ingestion before local storage/OTLP export. Defaults to 1.0. Env: OTEL_TRACES_SAMPLER_ARG. |
memory_max_spans |
number | Max spans to keep in memory. Defaults to 1000. Env: OTEL_MEMORY_MAX_SPANS. |
live_spans |
boolean | Mirror spans into the in-memory store at start as pending snapshots (see Live (pending) spans). Defaults to true for exporter: memory, false otherwise — set it to opt in with both in production. Ignored for otlp-only. Restart-tier. Env: OTEL_LIVE_SPANS. |
metrics_enabled |
boolean | Whether metrics collection is enabled. Defaults to false. Env: OTEL_METRICS_ENABLED. |
metrics_exporter |
string | Metrics exporter: memory or otlp. Defaults to memory. Env: OTEL_METRICS_EXPORTER. |
metrics_retention_seconds |
number | How long to retain metrics in memory. Defaults to 3600. Env: OTEL_METRICS_RETENTION_SECONDS. |
metrics_max_count |
number | Max metric data points in memory. Defaults to 10000. Env: OTEL_METRICS_MAX_COUNT. |
logs_enabled |
boolean | Whether structured log storage is enabled. |
logs_exporter |
string | Logs exporter: memory, otlp, or both. Use both when logs should remain queryable in iii while also being exported. Defaults to memory. Env: OTEL_LOGS_EXPORTER. |
logs_max_count |
number | Max log entries in memory. Defaults to 1000. |
logs_retention_seconds |
number | How long to retain logs in memory. Defaults to 3600. |
logs_sampling_ratio |
number | Fraction of logs to retain (0.0–1.0). Defaults to 1.0. |
logs_console_output |
boolean | Print ingested logs to the console. Defaults to true. |
level |
string | Minimum log level: trace, debug, info, warn, error. Defaults to info. |
format |
string | Log output format: default or json. Defaults to default. |
alerts |
AlertRule[] | Alert rules evaluated against metrics. |
trace_storage.enabled |
boolean | Persist completed spans to SQLite (traces.sqlite3). Defaults to true. |
trace_storage.directory |
string | Directory for the archive and its WAL sidecars. Defaults to ./data/observability/traces. |
trace_storage.max_disk_bytes |
number | Physical cap on the archive directory; oldest traces are evicted at 90% and the file shrunk toward 80%. No committed SQLite image exceeds the cap plus a tolerance of max(1%, 8 MiB); see Archive capacity for spans that can never fit, transients and readers. Defaults to 1073741824 (1 GiB), minimum 64 MiB. |
trace_storage.retention_seconds |
number | Age limit for archived traces; 0 disables it. Defaults to 2592000 (30 days). |
trace_storage.memory_max_bytes |
number | Hard cap on the in-memory hot cache (an RSS estimate). Oldest spans are evicted first — including finalized spans the archive has not written yet, which are counted in known_dropped_spans and turn completeness to partial. Defaults to 268435456 (256 MiB), minimum 16 MiB. |
trace_storage.memory_low_watermark_ratio |
number | Fraction of memory_max_bytes the cache shrinks to under pressure (0.5–0.95). Defaults to 0.75. |
trace_storage.pending_max_age_seconds |
number | Age after which a live (pending) snapshot that never closed is dropped from the hot cache. Defaults to 3600. |
trace_storage.max_attribute_bytes |
number | Cap on one attribute value (span, event or link) at ingest. Longer values are cut on a char boundary and end in …[truncated N bytes]; a cut iii.payload.json also sets iii.payload.truncated. 0 disables. Defaults to 65536. |
OTLP Transport
Traces and metrics export over OTLP/gRPC by default. https:// endpoints use TLS with system roots,
while http:// endpoints use cleartext transport.
To export traces and metrics with OTLP/HTTP protobuf instead of gRPC, set the standard OpenTelemetry protocol environment variable before starting the engine:
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobufSignal-specific protocol variables override the global value:
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/protobufWhen HTTP/protobuf is selected, iii treats endpoint as the collector base URL and appends the
signal path when needed:
- traces:
/v1/traces - metrics:
/v1/metrics
The logs exporter sends OTLP logs over HTTP and posts to /v1/logs.
Collectors that require authentication or routing headers can use the standard OTLP headers environment variables:
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $OTLP_TOKEN"Use signal-specific headers when one signal needs different values:
OTEL_EXPORTER_OTLP_TRACES_HEADERSOTEL_EXPORTER_OTLP_METRICS_HEADERSOTEL_EXPORTER_OTLP_LOGS_HEADERS
The logs exporter reads OTEL_EXPORTER_OTLP_LOGS_HEADERS first and falls back to
OTEL_EXPORTER_OTLP_HEADERS. Keep tokens in environment variables or a secret manager; do not
commit them to config files.
To keep the iii console useful while exporting to an external collector, use exporter: both for
traces and logs_exporter: both for logs.
Alert Rule Fields
| Field | Type | Description |
|---|---|---|
name |
string | Required. Unique alert rule name. |
metric |
string | Required. Metric name to monitor (e.g., iii.invocations.error). |
threshold |
number | Required. Threshold value. |
operator |
string | Comparison operator: greaterthan, greaterthanorequal, lessthan, lessthanorequal, equal, notequal. Defaults to greaterthan. |
window_seconds |
number | Time window in seconds for metric evaluation. Defaults to 60. |
cooldown_seconds |
number | Minimum interval between alert fires. Defaults to 60. |
enabled |
boolean | Whether the alert rule is active. Defaults to true. |
action |
AlertAction | { "type": "log" }, { "type": "webhook", "url": "..." }, or { "type": "function", "path": "..." }. |
Archive capacity
- Every commit attributes the pages it used (new plus reused free pages,
minus pages freed by replaced versions) to the traces it wrote, in
trace_meta.image_bytes; a commit of several traces shares them by an estimate of each trace's row cost (payload, attribute rows and their index entries). Capacity decisions use these sizes, never an archive-wide ratio. Archives from before the column gain it on open (0 = not measured;approx_bytesis used until the trace is written again). - Durable rows are never deleted to make room for their own trace. Before
anything is evicted, a trace's new spans are judged against the room it
could ever have beside its own rows. With no other trace in the archive
that is the room left now, an exact bound. Otherwise the sizes of the
others are only attributed (or unknown), so nothing is dropped on them:
the bound is the cap plus tolerance minus a lower bound of the trace's own
rows and the fixed overhead (schema pages,
-shm), and the spans are judged again, exactly, once the others are gone. Rows that never fit are dropped and counted inknown_dropped_spans(completeness: partial); the longest prefix that fits is written; rows that fit only once others leave wait. Later spans of the same trace are judged on their own. Of two dirty versions of a span, the newest is archived. - Nothing is evicted for spans that would not fit even with every
unprotected trace gone (protected = traces in the batch and live traces).
When no unprotected trace is left, the bytes in use are remembered as a
floor for the protected traces holding them, plus their attributed change
since. It holds while they stay protected and keep their rows (retention
keeps it; new limits,
clear, a floor trace losing its protection or rows of one being deleted drop it). A group of the only trace in the floor is judged against it, so spans that can never fit beside their live trace are dropped without evicting anything. Known limitation: without a floor sizes come from attribution, and a protected trace whose share of shared commits is underestimated, whose size is unknown (written by an older version), or the group's own rows (left out until measured) can make the first eviction for a group that still does not fit useless; that unprotected history is lost uncounted, once per set of protected traces, and the eviction measures the floor. - Large new spans (lower bound above a quarter of the cap and at most 64 MiB) are measured once in a private scratch database under the system temporary directory (mode 0600, 2 MiB page cache, removed right after, outside the cap; on tmpfs it is memory for one measurement). The measurement is kept for retries of the same rows. If the scratch cannot be used (missing or full TMPDIR) it is skipped for 10 minutes with one warning, and such spans are measured by a trial in the archive itself, with nothing evicted for them; larger spans always are.
- A trace that fits beside its own rows but not now (for example while a
live trace holds the space) stays dirty and is retried with backoff (1 s
doubling to 60 s); when nothing fits at all the whole archive backs off (up
to 30 s). A storage error (for example EFBIG/ENOSPC writing one large
trace) defers only the traces that hit it.
flushignores the backoffs; new limits andclearreset them; retention retries deferred traces at once, keeping their measurements and the floor. A periodic pass does at most 32 batches or 1 s of work before serving control requests; a longer backlog is progress, not degradation. flushreturnsOkonce every span ingested before the call is durable or counted as dropped; it returns an error if capacity or storage left some dirty (archivedegraded) or its 4 s budget ran out (not degraded). The budget is checked between batches, andflush,retain,clearandshutdownwait 5 s for the writer: one large batch (a measurement, eviction and trial of a span near 64 MiB) or a kernel I/O stall (slow fsync, journal commit, writeback) can exceed that. The call then returns a timeout error while the writer finishes the work; nothing is lost.- The cap counts the pages in use plus the
-shmindex. Freed pages return to the filesystem in checkpointed steps of 1024 pages, so retention or eviction adds about one step of WAL on top of the main file instead of a WAL as large as the space being freed. A reader holding a snapshot pins the WAL: the return pauses until the reader finishes. - The full-VACUUM fallback (for files whose pages do not return) runs at most once per 10 minutes. Archive warnings (capacity, drops, scratch and VACUUM failures) are limited to one log line per minute with a suppressed count.
Advanced Sampling
sampling:
default: 1.0
parent_based: true
rules:
- operation: "api.*"
rate: 0.1
rate_limit:
max_traces_per_second: 100Worker spans received over OTLP are sampled per trace before local storage and collector export, where the trace is rooted in the worker:
- A local root (no parent) gets the configured policy: rules, one rate-limit
token per trace, default ratio. The W3C sampled bit SDKs set on exported
spans (the Node SDK sends flags
0x101on every span) is not a decision and never bypasses these policies. - A span whose parent the SDK marks remote (OTLP flags
0x300together with aparentSpanId) carries the caller's decision: its sampled bit, set or not, is presented to the sampler as an incomingtraceparentwould be, so parent-based samplers (parent_based: true, and rules/rate limits, which always run parent-based) follow it and a plainsampling_ratiokeeps deciding by trace id. - Every other span follows its trace. A root anywhere in the same request
decides first; a trace already in the hot cache was sampled in. With
per-operation rules a child that arrives before its root waits for it, up
to 2 s, 4,096 spans and 32 MiB of estimated raw span data in total (held
spans are not yet truncated to
max_attribute_bytes; the decoded request itself is not counted), then is decided by its own span (rules are evaluated on the root, as the engine does). Without such rules the first span seen decides. - Verdicts are remembered for the 16,384 most recent traces and reset when
sampling changes. A root final whose verdict was forgotten keeps it while
the root's live start snapshot, stored since the last sampling change, is
in the hot cache; otherwise it is decided again. Engine spans under a worker's traceparent follow the
engine's own sampler (
ParentBased, unchanged): it trusts the worker's sampled flag, so withparent_based: trueand default 0 a worker-rooted trace keeps its engine spans while the worker's local root is sampled out.
Functions
Logging
| Function | Description |
|---|---|
engine::log::info |
Log an informational message. |
engine::log::warn |
Log a warning message. |
engine::log::error |
Log an error message. |
engine::log::debug |
Log a debug message. |
engine::log::trace |
Log a trace-level message. |
All logging functions accept: message (string, required), data (object), trace_id (string),
span_id (string), service_name (string).
Logs API
| Function | Description |
|---|---|
engine::logs::list |
Query stored log entries. Filters: start_time, end_time, trace_id, span_id, severity_min, severity_text, offset, limit. |
engine::logs::clear |
Clear all stored log entries from memory. |
Traces API
| Function | Description |
|---|---|
engine::traces::list |
List one compact summary per trace. Child spans contribute aggregate status/counts and can be searched with search_all_spans; attribute_projection returns only requested arbitrary attributes. Supports the standard trace filters, sort, offset, and limit. |
engine::traces::spans |
List full stored span records, including attributes, events, and links. Accepts the same filters and pagination as the former span-list contract; use this only for detail/timeline consumers that need complete span payloads. include_events: false drops each span's events and links, which carry the invocation payloads, for a view that only draws spans. |
engine::traces::tree |
Retrieve a trace as a hierarchical span tree. Parameters: trace_id (required). |
engine::traces::clear |
Clear all stored trace spans from memory. |
Live (pending) spans
With the memory exporter (the local-dev default), spans are additionally mirrored into the
in-memory store the moment they start. This is on by default for memory only; a both
setup — the production shape that also exports OTLP — opts in explicitly with live_spans: true
(or OTEL_LIVE_SPANS=true), and otlp-only can never mirror (engine finals don't land in the
memory store there, so pending snapshots would never be replaced). A live snapshot is marked
pending: true with end_time_unix_nano: 0 and carries the attributes known at creation (span
macro fields plus baggage-stamped iii.* attributes); attributes recorded later, like
otel.status_code, are absent, so pending spans read status: "unset". When the span closes, the
final span replaces the snapshot in place — same storage position, one entry per span id.
This is what lets live trace views show in-progress work: engine-side parent spans (trigger ,
enqueue, builtin call ) become visible while the work under them is still running, traces
appear in list views as soon as they start, and the trace triggers tick on span
start as well as close. Worker (SDK) spans still arrive only when they close — they are ingested
over OTLP, which has no notion of an unfinished span.
Consumers of the Traces API should treat pending: true (or end_time_unix_nano == 0) as
"still running": duration filters and sorts already measure such spans as duration-so-far, and
engine::metrics::list excludes them from latency statistics. The pending field is only
serialized when true, so the wire shape of finished spans is unchanged.
OTEL compliance: pending snapshots exist ONLY in the in-memory store (and the query views and
trace-trigger ticks fed from it). The OTLP export path (both the engine exporter and the SDK-span forwarder) is
untouched and only ever ships complete spans — end_time_unix_nano is semantically required on the
OTLP wire, and nothing partial ever reaches it. Spans that never close (e.g. a leaked guard) remain
pending until buffer eviction; they are a dev-store artifact, not exported data.
Metrics API
| Function | Description |
|---|---|
engine::metrics::list |
List metrics with aggregated statistics. Returns engine counters (invocations, workers, performance), SDK metrics, and optional time-bucketed aggregations. |
engine::rollups::list |
List metric rollup aggregations (1-minute, 5-minute, 1-hour windows). |
Other APIs
| Function | Description |
|---|---|
engine::baggage::get |
Get a baggage value from the current trace context. |
engine::baggage::set |
Set a baggage value in the current trace context. |
engine::baggage::get_all |
Get all baggage key-value pairs. |
engine::sampling::rules |
List all active sampling rules. |
engine::health::check |
Check engine health status. Returns status, components, timestamp, version. |
engine::alerts::list |
List all configured alert rules and their current state. |
engine::alerts::evaluate |
Manually trigger evaluation of all alert rules. |
Trigger Type: log
Register a function to react to log entries as they are produced.
| Config Field | Type | Description |
|---|---|---|
level |
string | Log level to subscribe to: info, warn, error, debug, or trace. When omitted, fires for all levels. |
Sample Code
const fn = iii.registerFunction("monitoring::onError", async (logEntry) => {
await sendAlert({
message: logEntry.body,
severity: logEntry.severity_text,
traceId: logEntry.trace_id,
});
return {};
});
iii.registerTrigger({
type: "log",
function_id: fn.id,
config: { level: "error" },
});Log entry payload fields: timestamp_unix_nano, observed_timestamp_unix_nano, severity_number,
severity_text, body, attributes, trace_id, span_id, resource, service_name,
instrumentation_scope_name, instrumentation_scope_version.
Trigger Type: trace
Register a function to react to span activity in the in-memory trace store, so any client — a worker
or the web console — can refresh reactively instead of polling. The trigger is a coalesced "traces
changed" tick, not a per-span feed: span activity is debounced (~300ms) and the handler receives
the distinct affected trace ids for the window. With live_spans on, the tick also fires when spans
start (pending snapshots land in the store), not just when they close. Re-read details via
engine::traces::list / engine::traces::spans / engine::traces::tree. Requires the memory exporter (exporter: memory or
both); in OTLP-only mode engine spans never land in the store, so the trigger only ever sees
OTLP-ingested SDK spans there.
Engine-internal spans and the trigger's own delivery spans are excluded from firing it —
delivering a trigger via engine.call is itself instrumented as a span, so without this exclusion
the trigger would re-fire on its own output (an unbounded feedback loop). This is why a span trigger
differs from the log trigger, whose delivery produces spans, not logs.
| Config Field | Type | Description |
|---|---|---|
service_name |
string | Only fire for activity from this service. When omitted, fires for any service. Compared case-insensitively. |
status |
string | Only fire when a span with this status (ok, error, or unset) landed in the window. When omitted, fires for any status. Compared case-insensitively. |
Both filters are ANDed; omit both to fire on any span activity.
Sample Code
const fn = iii.registerFunction("devtools::onTracesChanged", async ({ trace_ids }) => {
// A "refetch soon" beat — re-read the traces you care about.
await refreshTraceViews(trace_ids);
return {};
});
iii.registerTrigger({
type: "trace",
function_id: fn.id,
config: {}, // or { status: 'error' }
});Handler payload: { "trace_ids": string[] } — the distinct trace ids with span activity in the
coalesced window.