Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 36 additions & 37 deletions examples/sandbox_server.exs
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,31 @@ defmodule SandboxServer do
# Fresh, isolated in-memory store per request.
run_opts = [limits: @limits, storage: Pyex.Storage.Memory.new()]

# The footprint + capability ledger come straight off the return value
# on both paths — `ctx` on success, the `%Pyex.Error{}` on failure — so
# "what did it touch before it crashed?" needs no telemetry plumbing.
body =
case Pyex.run(source, run_opts) do
{:ok, value, ctx} ->
with_telemetry(%{verdict: "ok", stdout: Pyex.output(ctx), value: inspect(value)})
%{
verdict: "ok",
stdout: Pyex.output(ctx),
value: inspect(value),
usage: usage(Pyex.Turn.footprint(ctx)),
trace: Pyex.Turn.render(ctx)
}

{:error, %Pyex.Error{kind: :timeout, message: m}} ->
with_telemetry(%{verdict: "timeout", detail: m})
{:error, %Pyex.Error{kind: :timeout} = e} ->
audited(%{verdict: "timeout", detail: e.message}, e)

{:error, %Pyex.Error{} = e} ->
with_telemetry(%{
verdict: "error",
error: %{type: e.exception_type, kind: e.kind, message: e.message, line: e.line}
})
audited(
%{
verdict: "error",
error: %{type: e.exception_type, kind: e.kind, message: e.message, line: e.line}
},
e
)
end

send(parent, {:done, self(), body})
Expand Down Expand Up @@ -132,39 +144,26 @@ defmodule SandboxServer do
end
end

# Folds the run's footprint + capability ledger (captured from Pyex's telemetry
# in THIS worker process) into the verdict body. Present whenever the run
# produced a result — including failures.
defp with_telemetry(body) do
case Process.get(:pyex_run) do
{footprint, metadata} ->
spans = Map.get(metadata, :runtime_spans, [])

body
|> Map.put(:usage, %{
steps: footprint[:steps],
compute_ms: footprint[:compute],
duration_ms: footprint[:duration_ms],
memory_bytes: footprint[:memory_bytes],
output_bytes: footprint[:output_bytes]
})
|> Map.put(:trace, Pyex.SpanTree.render(spans, title: "runtime · scope=pyex"))
# Folds a failed run's footprint + capability ledger (carried on the error)
# into the verdict body. Both are empty/nil for errors raised before execution.
defp audited(body, %Pyex.Error{footprint: nil}), do: body

_ ->
body
end
defp audited(body, %Pyex.Error{footprint: footprint, runtime_spans: spans}) do
body
|> Map.put(:usage, usage(footprint))
|> Map.put(:trace, Pyex.SpanTree.render(spans, title: "runtime · scope=pyex"))
end
end

# One telemetry handler captures each run's footprint + capability ledger into
# the emitting worker's process dictionary (handlers run in the caller's
# process), for `with_telemetry/1` to read back. Covers success and failure.
:telemetry.attach_many(
"sandbox-capture",
[[:pyex, :run, :stop], [:pyex, :run, :exception]],
fn _event, measurements, metadata, _ -> Process.put(:pyex_run, {measurements, metadata}) end,
nil
)
defp usage(footprint) do
%{
steps: footprint[:steps],
compute_ms: footprint[:compute],
duration_ms: footprint[:duration_ms],
memory_bytes: footprint[:memory_bytes],
output_bytes: footprint[:output_bytes]
}
end
end

port = String.to_integer(System.get_env("PORT", "4599"))
{:ok, _} = Bandit.start_link(plug: SandboxServer, port: port)
Expand Down
29 changes: 23 additions & 6 deletions lib/pyex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ defmodule Pyex do
The optional second argument can be a `Pyex.Ctx` struct
or a keyword list of options (forwarded to `Pyex.Ctx.new/1`).

Returns `{:ok, value, ctx}` on success, or `{:error, reason}`.
Returns `{:ok, value, ctx}` on success, or `{:error, %Pyex.Error{}}`.

On failure the error carries the run's `runtime_spans` (the capability
ledger of what the program touched before it broke) and `footprint` (its
resource usage), so a failed run is auditable from the return value alone —
no telemetry handler required. Both are empty/`nil` for errors raised before
execution (e.g. syntax). On success the same is read from `ctx` via
`Pyex.Turn` and `Pyex.Ctx.runtime_spans/1`.

## Options (when passing keyword list)

Expand Down Expand Up @@ -218,15 +225,25 @@ defmodule Pyex do
1000.0

final_ctx = %{final_ctx | duration_ms: duration_ms}
error = Error.from_message(msg)

# A failed turn still surfaces what it touched: the capability ledger
# of everything done before the error rides on the exception event,
# so a host/agent can audit a crash even though `run` returns no ctx.
# and resource footprint of everything done before the error are
# carried on the returned error (and the exception event), so a host
# or agent can audit a crash from the return value alone — no
# telemetry handler required.
footprint = Pyex.Turn.footprint(final_ctx)
runtime_spans = Ctx.runtime_spans(final_ctx)

error = %{
Error.from_message(msg)
| footprint: footprint,
runtime_spans: runtime_spans
}

:telemetry.execute(
[:pyex, :run, :exception],
Pyex.Turn.footprint(final_ctx),
%{error: error, runtime_spans: Ctx.runtime_spans(final_ctx)}
footprint,
%{error: error, runtime_spans: runtime_spans}
)

{:error, error}
Expand Down
11 changes: 9 additions & 2 deletions lib/pyex/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,21 @@ defmodule Pyex.Error do
limit: limit_type(),
message: String.t(),
line: pos_integer() | nil,
exception_type: String.t() | nil
exception_type: String.t() | nil,
runtime_spans: [map()],
footprint: map() | nil
}

defstruct kind: :internal,
limit: nil,
message: "",
line: nil,
exception_type: nil
exception_type: nil,
# The capability ledger and resource footprint at the point of
# failure — what the program touched and how much it spent before it
# broke. Empty/nil for errors raised before execution (e.g. syntax).
runtime_spans: [],
footprint: nil

@doc """
Classifies a raw error string into a structured error.
Expand Down
19 changes: 19 additions & 0 deletions test/pyex/error_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,23 @@ defmodule Pyex.ErrorTest do
assert err.kind == :timeout
end
end

describe "the error carries the capability ledger and footprint of a failed run" do
test "a failed run's error carries the span ledger of what it touched first" do
src = "import store\nstore.set('k', 1)\nstore.get('k')\n1 / 0"
{:error, %Error{} = err} = Pyex.run(src, storage: Pyex.Storage.Memory.new())

# Auditable from the return value alone — no telemetry handler needed.
assert Enum.map(err.runtime_spans, & &1.name) == ["db.set", "db.get"]
assert err.footprint[:steps] > 0
end

test "an error raised before execution carries an empty ledger and no footprint" do
{:error, %Error{} = err} = Pyex.run("def (:")

assert err.kind == :syntax
assert err.runtime_spans == []
assert err.footprint == nil
end
end
end
Loading