Execution log
What inis.run records about a session's lifecycle and network activity, and how to read it back.
Every session emits a record of what actually happened — created, executed, paused, destroyed — into a hash-chained execution log. A session running under an egress allowlist also records every outbound network attempt, allowed or denied; a session on the default full-egress policy records no network events at all. This page covers the event types, the egress payload shape specifically, and how to read events back over the API.
Reading the log
This lives on the account/control-plane API (https://app.inis.run), not the
execution API — it's gated by your signed-in cookie session in the
console, the same as /v1/keys and /v1/usage, not by an Authorization: Bearer token. Calling it with a bearer key returns 401 {"error":"not authenticated"}. The console's own Logs view calls this endpoint from the
browser — that's the primary way to read it today.
Query parameters:
| Param | Description |
|---|---|
from / to | RFC3339 time range. Defaults to the last 7 days ending now. |
limit | Page size, default 100, max 1000. |
session_id | Filter to one session. |
type | Filter by event type — repeatable and/or comma-separated (e.g. type=egress.denied). |
q | Free-text search over the event type and payload. |
cursor | Page token from a previous response's next_cursor, for paging further back. |
Response shape:
{
"from": "2026-07-28T19:00:00Z",
"to": "2026-08-04T19:00:00Z",
"next_cursor": "",
"events": [
{
"timestamp": "2026-08-04T19:11:02.481Z",
"region": "eu",
"session_id": "9hmquuxiz7qyqdqiwfvep",
"org_id": "org_...",
"project_id": "proj_...",
"event_type": "egress.denied",
"data": {
"dst": "93.184.215.14",
"port": 443,
"proto": "tcp",
"domain": "example.com",
"policy_default": "deny",
"escalation_available": true
},
"node_id": "00000000-0000-0000-0000-000000000000",
"seq": 4821,
"prev_hash": "…",
"event_hash": "…",
"chain_verified": true
}
]
}Each event carries prev_hash/event_hash/chain_verified so you can
verify the sequence hasn't been altered independently of trusting this
response. The data field above is a representative shape built from the
source payload definition; the values (destination, domain, node) are
illustrative, not a literal captured response — reading a real one requires a
browser session, not a bearer key.
Egress event types
| Event | When |
|---|---|
egress.allowed | An outbound connection matched the session's allowlist. |
egress.denied | An outbound connection was blocked because it didn't match. |
egress.policy_updated | POST /v1/sessions/{id}/egress changed a live session's policy. |
Two more names — egress.approval_requested / egress.approval_granted /
egress.approval_denied — exist in the event vocabulary for a future
just-in-time approval flow (a blocked attempt gets escalated to a human
rather than staying blocked). They are not emitted today: nothing in the
codebase produces them yet. The escalation_available field on a denial
(below) is the only live signal of that direction — it tells you a control-
plane path exists to widen the policy, not that the approval flow itself is
built.
Payload fields (EgressData)
| Field | Type | Notes |
|---|---|---|
dst | string | Destination IP. |
port | int | Destination port. |
proto | string | tcp, udp, icmp, or proto-<n>. |
bytes | int64 | Observed bytes across the aggregated group (see below). |
count | int | Number of identical attempts folded into this one event. |
domain | string | Hostname the attempt named — the DNS query name or the TLS SNI. Always present on a denial, and on an allow too — every egress.* event comes from a deny-mode session, because an allow-mode session produces none (see below). |
policy_default | string | The session's egress mode (deny or allow) at the time of the attempt, so a denial carries the reason it happened. |
escalation_available | bool | Set on a denial when a control-plane path exists to widen the policy — groundwork for the approval flow above, not a working escalation UI yet. |
Aggregation, not sampling. A guest retrying a blocked host in a loop (a
resolver's own retry logic, or a script busy-looping on a denied domain)
doesn't produce one audit row per attempt. Repeated denials for the same
(session, domain) pair within a short window are coalesced into one event
with count incremented, so a retry storm shows up as one entry with a
count, not a flood.
The honest limit on count. Packet capture is itself rate-limited per
session — on the order of 100 packets/second by default, applied separately to
the allowed and denied paths — so a scanning guest cannot flood the capture
path. Enforcement is unaffected: a packet over that rate is still allowed or
dropped exactly as the policy says. But it is not captured, so under a
sustained storm count is a floor, not an exact tally. At ordinary traffic
rates every attempt is captured.
Why egress.* events are deny-mode-only. The capture path is part of the
enforcement machinery, not a separate observer: the packet-logging rules are
installed alongside the allowlist when a deny-mode policy is applied, and torn
down when the policy reverts to allow. So an allow-mode session (full public
egress, the default) produces no egress.* events whatsoever — not
destination-less ones, not IP-only ones, none. If you need a network record for
a workload, run it under an allowlist. In deny mode, domain is populated on
both allowed and denied attempts, captured by the host's DNS- and TLS-SNI-aware
egress filters described in Networking & egress
before a packet ever reaches IP-level capture — a blocked DNS query never
generates a routable packet, and an allowed spliced TLS connection never
traverses the ordinary forwarding path either, so without this the deny-mode
case would otherwise be invisible even though it was correctly enforced.
Payload fields (EgressPolicyUpdateData)
egress.policy_updated is a config-change record, not an observed connection
— it fires once, after POST /v1/sessions/{id}/egress
has actually taken effect on a live session, and carries both sides of the
change so you don't have to diff it against an earlier session.created (or
policy-update) event by hand.
| Field | Type | Notes |
|---|---|---|
previous_mode | string | The mode (deny or allow) in effect immediately before this change. |
previous_allow | []string | The allowlist that went with previous_mode. Only meaningful when previous_mode is deny. |
mode | string | The mode now in effect. |
allow | []string | The allowlist that goes with mode. Only meaningful when mode is deny. |
A request that fails to apply (the enforcer can't actually install a
tightened policy) never reaches this event — the session's policy reverts and
no event is emitted, so egress.policy_updated never claims a change that
didn't genuinely take effect.
Why egress events aren't on the live event stream
GET /v1/events (the account-scoped SSE stream used for a live "what
changed" feed) deliberately excludes egress.*: a busy deny-mode session can
generate enough denied-connection attempts to flood a live per-connection
stream, and that risk hasn't been closed off yet (gate behind server-side
?types= filtering once a console consumer exists). Egress events still show
up in the historical export above — GET /v1/audit/events — they're just not
pushed live.
Live-only transitional states
GET /v1/events also carries event types you will never see on this
page's GET /v1/audit/events export or in the console's Logs view:
session.creating, session.pausing, and session.resuming fire once, the
moment a session enters that in-progress state — most usefully for a resume,
which can take anywhere from milliseconds (a warm, same-node restore) to over
a minute (restoring from a cold, cross-node archive) — so a client watching
the stream can tell "another actor's transition is under way" apart from "the
stream stalled," instead of seeing nothing until the matching
session.created / session.paused / session.resumed event lands.
session.active_window_warning is the one member of this group that isn't a
state entry: it fires a few minutes before the continuous-active-window
backstop is about to force-pause (and,
normally, immediately blip-resume) a session that has been live for the full
window — a heads-up for an in-guest agent or process to wrap up before the
blip force-closes any attached PTY, tunnel, or in-flight operation. The
session itself does not change state when this fires; it stays live until
the backstop's own pause a few minutes later.
These are a live-only UI hint, not part of the execution log this page
otherwise documents: they are never hash-chained, never stored in the hot
tier, and never replayed on an SSE reconnect. A GET on the session (or the
terminal event, which is durable) is always the source of truth for where a
session actually landed.
Refusal events
Two event types describe something that did not happen: the node was too full to take the work, and the disk gate refused it.
| Event | Fires when |
|---|---|
session.wake_refused | A paused session could not be woken — an explicit resume, an auto-resume, or a cold adopt — because its node is at or above the no-resume line, or because the account is at its concurrent live-session limit (reason: account_concurrency). |
session.create_refused | A create, fork or restore was refused by the node's disk gate: the request did not fit above the node's reserve, the node is at or above the no-create line, or the node could not measure its own disk. |
They exist because the party who needs to know is often not the caller. A
preview URL is usually driven by an inbound webhook (a chat platform posting
an update), so the application that holds the conversation never made the
request and would otherwise see nothing — the room simply stops answering.
Subscribe to these and you can tell the person on the other end "not right
now, try again shortly" instead of going quiet. The same refusal reaches a
direct caller as a 503 with
code: "node_disk_pressure" and a Retry-After header.
Payload:
| Field | Type | Notes |
|---|---|---|
reason | string | bytes (the request itself did not fit above the node's reserve), pct (the node is at or above the ladder line), unmeasured (the node could not read its own disk, so nothing was decided on numbers), or account_concurrency (a wake refused because the account already has as many live sessions as its limit allows; the caller got a 429 with code: "account_concurrency_exceeded"). |
line | string | no-resume for a refused wake, no-create for a refused create. |
node_id | string | The node that refused. Useful to quote to support, and to tell "one node is full" from "the fleet is full". |
used_pct | number | The node's fullness at the moment of refusal. Absent on an unmeasured refusal — nothing was measured, and 0 would be a lie. |
effective_avail_bytes | number | Free space the node could actually grow into. Absent on an unmeasured refusal for the same reason. |
account_active, account_limit | number | The account's live sessions on the node and its limit there. Present only on an account_concurrency refusal. |
retry_after_seconds | number | The same hint the Retry-After header carries, repeated here for a subscriber that never saw the response. |
template, size | string | What was asked for. Present on session.create_refused, which usually has no session_id — the session was never created. |
session_id is present on every session.wake_refused and absent from most
session.create_refused events: there is no session to name when the create
is what was refused. Two cases do carry one — a restore-in-place, which
refuses against a session that already exists, and a create refused on the
second, post-template-resolution disk check, by which point the session has
an id and is being built.
These are debounced. A refused wake is retried aggressively by whatever
is on the other end, so at most one session.wake_refused per session (and
one session.create_refused per account, per node) is emitted per
retry_after_seconds window. The event tells you a room is being refused
now; it is not a log of every attempt. The refusals themselves are not
debounced — every attempt is still refused, and still answered with the coded
503.
Bandwidth limits
Two independent caps sit alongside the allowlist and apply regardless of egress mode:
- Per-VM bandwidth ceiling — a flat outbound rate cap per session,
applied on the host with
tc, default 100 Mbit/s. Configurable withINIS_EGRESS_CAP_MBIT(operator-side, not a per-request field). Sustained traffic over the cap is throttled, not blocked — a short burst above it is tolerated before shaping kicks in. - Per-account daily byte cap — a rolling UTC-day ceiling on total egress
bytes across all of an account's sessions, default 10 GiB. Configurable
with
INIS_EGRESS_DAILY_CAP_GB(0disables it). This one isn't a soft throttle: going over it suspends the account and destroys every live session on it — it's a hard stop against sustained abuse (bulk scraping, exfiltration, DDoS participation via forked sessions), not a rate limiter.
Both are fleet-operator settings today, not something you configure per session or per project.