Observability
Logs live in Loki and nowhere else: app instance output, build output and
generic sandbox output are pushed there, and the web UI, the admin API and
deno deploy logs read them back from it. Apps also export OpenTelemetry traces
and metrics through Deno's built-in OTLP exporter, using the same channel Deno
Deploy uses: vsock to the host. The app never sees an endpoint, a tenant or a
credential; hh.pe attributes data to the sandbox it came from and adds those on
the host.
Logs
| Source | Loki stream labels | Structured metadata |
|---|---|---|
App instance stdout/stderr and lifecycle events ( |
|
|
Build output: shell trace ( |
|
|
Generic sandbox agent and envd output, lifecycle events |
|
|
OTel log records an app emits itself (OTLP, see below) |
|
|
Lines are batched for 250 ms (1000 lines max) and retried every 2 s while Loki
is unreachable, keeping at most 20000 pending lines per instance, build or
sandbox. Every build step's output is flushed before the build status changes,
so a finished build's log is complete. Console output goes through stdout and
stderr (OTEL_DENO_CONSOLE=ignore): Deno's OTLP log batches are lost when an
instance is stopped before they are exported, stdout is not.
Readers: the app page shows the last 1000 lines of the last 7 days, build and
sandbox pages everything since the revision or sandbox was created,
GET /apps/<app>/logs?lines=N and GET /apps/<app>/revisions/<rev>/log return
plain text, and deno deploy logs prints the history and then follows. Queries
span at most 30 days (Loki's default max_query_length). Every reader goes
through one place in sandboxod (sandboxod/otel.ts): identical windows asked
for within 2 s share one Loki query, and the org page's usage totals share one
Mimir query per 15 s.
The web UI renders those lines with the page and then streams new ones as
server-sent events from /otel/logql; deno deploy logs continues the same
way. Every follower of the same log, in the UI or the CLI, shares one Loki
live tail (/loki/api/v1/tail, a WebSocket; delay_for holds lines for 1 s
so late writers still arrive in order), so a new line shows up about a second
after it was written. Loki allows 10 tails per tenant by default
(max_concurrent_tail_requests) and ends each after an hour
(tail_max_duration); sandboxod reconnects, and polls query_range every
second while Loki refuses a tail. Metric charts refresh from /otel/promql
every step while the tab is visible; identical chart queries from several
viewers share one Mimir request per step. Both endpoints only run the query
and tenant the page signed (see signing_secret below).
Without telemetry.loki logs are dropped (with a warning at startup) and the
readers report that log storage is not configured.
OpenTelemetry
app (deno, OTEL_DENO=true, OTEL_DENO_VSOCK=2:4318)
-> AF_VSOCK connect(cid 2, port 4318) gVisor sentry (bundled runsc)
-> /run/vsock/2/4318 in the sandbox
-> slots/<network>/sandbox-<id>/vsock/2/4318 on the host
-> hh.pe OtlpForwarder (one receiver per running instance)
/v1/traces -> telemetry.otlp.traces
/v1/metrics -> telemetry.mimir.url + /otlp/v1/metrics
/v1/logs -> telemetry.loki.url + /otlp/v1/logs
+ X-Scope-OrgID: <tenant>, + configured headers
-
gVisor has no vsock. The bundled runsc ships a sentry with an
AF_VSOCKsocket provider: vsock sockets are unix stream sockets underneath, and(cid, port)maps to/run/vsock/<cid>/<port>(guest CID 3, host CID 2), the same convention Firecracker uses for guest-initiated vsock connections. The runtime sandboxes mount a host directory at/run/vsockand run with--host-uds=all. -
When an instance starts, hh.pe opens an HTTP server on that sandbox's
vsock/2/4318and sends these variables in the start line, after the app's own env so the app config cannot override them:OTEL_DENO=true,OTEL_DENO_VSOCK=2:4318,OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf,OTEL_DENO_CONSOLE=ignore,OTEL_SERVICE_NAME=<app>,OTEL_RESOURCE_ATTRIBUTES=service.version=<rev>,service.instance.id=<replica>. OtherOTEL_*settings from the app env apply, for exampleOTEL_METRIC_EXPORT_INTERVAL. -
The forwarder accepts OTLP/HTTP (protobuf or JSON, compressed or not) up to 16 MiB per request, forwards it with a 10 s timeout and answers 503 when the backend is unreachable, so the exporter retries. Signals without a configured URL are accepted and dropped.
-
The tenant is set by the host, so an app can only write into its own tenant, even if it speaks OTLP over
/run/vsockitself.
Only app instances export OpenTelemetry; builds and generic sandboxes only log.
Export is on when Loki, Mimir or a traces URL is configured; otherwise apps get
no OTLP variables. Runtime sandboxes always have vsock: the cron socket
(vsock/2/4320, DENO_UNSTABLE_CRON_SOCK) uses it too.
Usage
The organization page (/<org>) shows requests, CPU time, outbound and inbound
traffic of the last 24 hours, in total and requests per app. hh.pe meters this
itself, so it does not depend on what an app exports:
-
requests, inbound and outbound bytes are counted by the zone for every request it forwards to an app: request line, headers and body in, status line, headers and body out (as sent to the client, after decompression). Traffic of an app's own outgoing connections is not counted;
-
CPU time is the host CPU of the instance's sandbox (sentry and application), read from its cgroup
cpu.statevery flush and when it stops, minus the value when it was taken from the pool, so the warm V8 boot is not billed. This needs runtime sandboxes in cgroups (runtime_cgroup_parent,autoby default); zones without them are named on the organization page and their CPU time stays-. gVisor's own accounting (runsc events --stats) is not used: it reports about half a core for an idle Deno; -
every
telemetry.usage_interval_ms(15 s) the deltas since the last flush are pushed to Mimir as OTLP gaugessandboxo.app.requests,sandboxo.app.cpu.seconds,sandboxo.app.inbound.bytesandsandboxo.app.outbound.byteswith anappattribute, in the org tenant. The page sums them withsum_over_time(...[24h]), which is exact and survives restarts. Deltas that fail to push are kept for the next flush.
Metrics
/<org>/<app>/metrics charts the app over the last 15 minutes, hour, 6 hours,
day or week, about 120 points per chart (step 15 s for 15 minutes, 30 s for an
hour, 5 minutes for a day, 1.5 hours for a week), times in UTC:
| Chart | Source | Query |
|---|---|---|
Requests |
usage, org tenant |
|
Latency |
Deno, app tenant |
|
Responses |
Deno, app tenant |
|
CPU |
usage, org tenant |
|
Traffic |
usage, org tenant |
|
Memory |
Deno, app tenant |
|
Charts are server-rendered SVG in a MetricChart island that adds a crosshair
and the values under the cursor. Deno exports metrics every 60 s by default
(OTEL_METRIC_EXPORT_INTERVAL), so the latency and response charts lag by up to
a minute and stay empty for apps without OTel export.
Traces
/<org>/<app>/traces lists the app's requests from Tempo (telemetry.tempo,
queried with the app's tenant): one row per root span, newest first, with the
revision identicon, method and path, HTTP status and duration. It shows up to
100 traces of the last 15 minutes to 7 days, optionally only failed requests
(span status error or a 5xx response) or only one revision ("Traces of this
revision" on a build page). The query is TraceQL:
{ resource.service.name = "<app>" && nestedSetParent < 0 [&& ...] }
| select(span.http.request.method, span.url.path,
span.http.response.status_code, span.cron.name,
resource.service.version, status)
with (most_recent=true)
The search box takes the cron filters of Deno Deploy: kind:cron adds
span.cron.name =~ ".+", cron.name:<name> and cron.schedule:<schedule>
(quote values with spaces, cron.schedule:"0 * * * *") match those span
attributes; span.cron.name is selected as well. Tempo reports selected
attributes a span does not have as the string nil, which the list treats as
missing.
Cron runs are traced from the platform side: the scheduler mints the trace id
and passes it to deno as traceparent, and the zone exports a root span
cron <name> (internal, cron.name, cron.schedule, cron.attempt,
cron.run.id, error status with the failure reason) as OTLP/JSON to the traces
URL with the app's tenant and resource. deno's own deno.cron span, with the
handler's exception, and everything the handler does nest under it. The Cron
pages link each run to its trace.
A trace page (/<org>/<app>/traces/<id>, from Tempo's /api/v2/traces/<id>)
shows the spans as a waterfall: nested by
parent, bars on the trace's time axis, colored by kind (server, client,
internal) and red on error. Opening a row shows its attributes, events (such as
recorded exceptions) and status message. Below are the app's logs from 20 ms
before the trace to 100 ms after it, for the trace's revision: console lines
carry no trace id, so this is a time window and can include lines of concurrent
requests. Times on these pages are UTC.
Configuration
Zones push: app, build and sandbox logs, the OTLP forwarder and usage metering
run in zoned, so a zone needs the Loki, Mimir and traces URLs and headers.
Usage gauges carry a zone attribute. sandboxod pushes only the build lines it
writes itself (git fetch, dispatch failures) and queries Loki, Mimir and Tempo
for the web UI and the APIs. Both take the same telemetry section in their
config (see Configuration); header values are
secrets:
"telemetry": {
"loki": { "url": "https://loki.example.com", "headers": { "Authorization": { "_env": "LOKI_AUTH" } } },
"mimir": { "url": "http://127.0.0.1:9009" },
"tempo": { "url": "http://127.0.0.1:3200" },
"otlp": { "traces": "http://127.0.0.1:4318/v1/traces" }
}
| Key | Meaning |
|---|---|
|
Loki base URL, for example |
|
Extra headers for every Loki request, for example credentials of a hosted Loki |
|
Full OTLP/HTTP traces URL, for Tempo |
|
Extra headers for forwarded traces |
|
Largest forwarded OTLP request (16 MiB) and the forward timeout (10 s) |
|
Tempo query API base URL, for example |
|
Extra headers for Tempo queries |
|
Mimir base URL, for example |
|
Extra headers for every Mimir request |
|
|
|
How often zones push usage deltas (15 s) |
|
Cgroup parent for runtime sandboxes, |
|
Required, a secret of at least 32 characters, keep it
stable: it signs the query URLs pages hand to the log and chart islands (valid
for 12 hours and only with the same login) and the e2b template upload links.
Rotating it stops open pages from refreshing until they are reloaded. The dev
config reads a fixed value from the dev shell's |
Local backends
process-compose.yaml runs Grafana Mimir, Tempo and Loki from nixpkgs (all in
the dev shell), single binary each, multi-tenant, listening on 127.0.0.1 only.
Their object storage is a single-node Garage with buckets loki, mimir and
tempo, next to the sandboxo bucket for hh.pe blobs; garage-init
(nix/garage-init.sh) assigns the layout, imports the dev key and creates the
buckets before the backends start. Redis (AOF persistence) holds hh.pe records.
The dev shell sets the shared dev credentials (GARAGE_RPC_SECRET,
SANDBOXO_S3_ACCESS_KEY_ID, SANDBOXO_S3_SECRET_ACCESS_KEY) and
SANDBOXO_REDIS_URL, which the dev config reads through _env placeholders,
so run process-compose and dev.ts from it. Local state (WAL, Garage
metadata and data, the AOF) lives in observability/data/, configs in
observability/:
| Backend | HTTP | Other ports |
|---|---|---|
Mimir |
9009 |
gRPC 9095 |
Tempo |
3200 |
OTLP gRPC 4317, OTLP HTTP 4318, gRPC 9195 |
Loki |
3100 |
gRPC 9096 |
Garage |
S3 3900 |
RPC 3901, admin 3903 |
Redis |
- |
6380 |
The dev config (SANDBOXO_DEV_CONFIG, see dev.ts)
points hh.pe at them through its telemetry section, and the dev shell
configures process-compose itself (PC_CONFIG_FILES=process-compose.yaml, API
on PC_PORT_NUM=18080):
nix develop
process-compose up # TUI; add -t=false to run headless
SANDBOXO_TOKEN=secret deno task dev
Every query needs the tenant header, for an app hello:
curl -H 'X-Scope-OrgID: hello' 'http://127.0.0.1:3200/api/search?tags=service.name%3Dhello'
curl -H 'X-Scope-OrgID: hello' 'http://127.0.0.1:3100/loki/api/v1/query_range?query=%7Bservice_name%3D%22hello%22%7D'
curl -H 'X-Scope-OrgID: hello' 'http://127.0.0.1:9009/prometheus/api/v1/label/__name__/values'
In Grafana, add one Prometheus (http://127.0.0.1:9009/prometheus), Tempo and
Loki data source per tenant with the X-Scope-OrgID header set.
Mimir must advertise 127.0.0.1 for every ring and for the query frontend
(frontend.address); with an auto-detected interface address its queriers
cannot report finished queries and every query hangs. Loki's ingestion limits
are raised (32 MB/s, 64 MB bursts) so large build logs are not rejected.
The smoke tests that read logs (smoke/deploy.ts, smoke/compat.ts,
smoke/build_limits.ts, smoke/ui.ts) need the backends running and exit
early otherwise. smoke/otel.ts also checks that an app's traces, logs and
HTTP metrics arrive under its tenant, that another tenant sees none, that its
requests are counted exactly on the organization page and, with
SANDBOXO_RUNTIME_CGROUP set (auto works in a user session; the smoke
passes it as runtime_cgroup_parent), that its CPU time is
plausible.