Skip to content

Align observability and runtime contracts - #59

Open
cardmagic wants to merge 11 commits into
mainfrom
feat/portable-observability
Open

cardmagic wants to merge 11 commits into
mainfrom
feat/portable-observability

Conversation

@cardmagic

@cardmagic cardmagic commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Implement #42 with the shared portable event schema and actor diagnostics, and close the runtime drift found against Ruby #80.

  • Pin payload isolation, staged-work rejection, configured UTF-8 size limits, and timeout activation metadata with matching Ruby/JS regressions and shared timeout fixtures. Ruby adopts the existing JS payload guarantees in the paired PR.
  • Reject observable replacement of staged work even when the intent count stays unchanged, matching the Ruby fix.
  • Preserve reserved JSON keys such as __proto__ through normalization, byte limits, actor state, and retained results, using shared Ruby/JS fixtures.
  • Add immutable telemetry, isolated exporters, authorized actor observers, bounded diagnostics, and SQL/Durable Objects lifecycle coverage.
  • Preserve transmit staging order within a source message. A deterministic regression previously delivered [2, 1]; it now delivers [1, 2] even after a retry.
  • Reject explicit null transmit arguments and extend the shared fixtures. Refresh the parity ledger for message-read authorization/results, polling telemetry, redrive/audit, reminders, and automatic wake-up.
  • Share the portable event contract compatibility/telemetry-events.json with Ruby. Both suites check the attribute allowlist and the keys of each core SQL event against it. Activation events now send ownerId, commit action events send activationGeneration, reminder.enqueued sends messageId and attempt, and a truncated mailbox.depth sample sends depth: null.
  • Port the Ruby purity tests for each kind of staged work. Correct the dead transmit retry API in the docs, document the actor_diagnostics authorization, and remove the unused compatibility/ruby.yml.
  • Reject an observer without an onEvent callback with TypeError before authorization, as Ruby raises ArgumentError without a block. Port the Ruby tests for the 1,000-observer limit and for observer removal on close().
  • Add a setup sample for each runtime to the shared observability guide, and remove the delivered observability section from the roadmap.
  • Patch the Cloudflare tooling Undici dependency; the security audit passes.

Examples

These snippets extend an application's existing configuration and registered ShoppingCart actor. operator is the current authenticated caller; the application's message and administration policies must authorize it.

Send portable events and metric samples to your logger

import { configure } from "solid-objects"

const runtime = configure({
  ...applicationConfiguration,
  instrumentation: (event) => {
    console.info(JSON.stringify(event))
  },
})
await runtime.install()

Events include schemaVersion, actor/message correlation, safe attributes, and metric samples. The same JSON fields work in Ruby and JS, including polling intervals in milliseconds. Arguments, state, results, and exception text are excluded; exporter failures do not fail actor work.

Watch one actor and inspect its queues

const cart = runtime.ref(ShoppingCart, "demo-cart")
const stop = await cart.on("message.retry", {
  authorizationContext: operator,
  onEvent: (event) => console.info(JSON.stringify(event)),
})

const summary = await cart.diagnostics({
  authorizationContext: operator,
  limit: 50,
})
console.info(summary.mailbox, summary.outbox, summary.recoveryFailures)
stop()

Administration policies must allow observe and inspect on actor_diagnostics. Each category exposes sampled, truncated, and oldestAgeMilliseconds. Use cart.observe(...) for all of this actor's events. Observers are local to the SQL runtime's process; remote Durable Objects use host instrumentation.

Recover a background result after losing the original handle

Assume checkout({ orderId }) returns { orderId }. Enqueue it with a stable key:

await cart.send
  .with({ idempotencyKey: "checkout-42", authorizationContext: operator })
  .checkout({ orderId: 42 })

After a worker processes it, a later request can recover the result:

const message = await cart.findBy({
  idempotencyKey: "checkout-42",
  authorizationContext: operator,
})
if (message) {
  const result = await message.result({ authorizationContext: operator })
  const outcome = await message.outcome({ authorizationContext: operator })
  console.info(result, outcome.status, outcome.attempts)
}

A completed result is { orderId: 42 }. Every read checks current authorization against the original operation and arguments. result() raises a persisted rejection or permanent failure; outcome() exposes the failure as data. The paired Ruby PR brings Ruby background results and read authorization into this same contract.

Keep two transmits in staging order through retries

Inside an actor operation, stage two calls to its server twin's declared append operation:

this.transmit().append({ value: 1 })
this.transmit().append({ value: 2 })

With the transmit handler registered, the receiver applies 1 before 2, even if delivery of 1 initially fails. Migration 14 persists the staging position; random effect IDs no longer reorder calls from the same turn. The receiver deduplicates repeated envelopes.

Diagnose why a synchronous call timed out

Filter solid_objects.sync.timeout events to see whether an activation, an earlier
message, a paused actor, or database contention prevented progress. The Ruby and
JS SQL runtimes now retain the same fields and reason values. The Durable Objects
host sends no sync.timeout event; its call timeouts report waitingOn: "unknown".
For example, an event can include:

{
  "name": "solid_objects.sync.timeout",
  "attributes": {
    "waitingOn": "activationHeld",
    "activationOwnerId": "worker-1",
    "activationGeneration": "7"
  }
}

Unknown activation fields are null. These are correlation fields, not metric labels.

Upgrade and boundaries

Run runtime.install() before starting upgraded workers. Schema migration 14 adds effects.position. Legacy rows retain position zero and their existing ID tie-break; their original staging order cannot be reconstructed. New effects preserve staging order.

Actor observers are process-local on SQL; Durable Objects uses host instrumentation and remote diagnostics. The shared event contract applies to the SQL runtimes; Durable Objects events carry fewer attributes. A truncated mailbox.depth sample now sends depth: null instead of no depth key. Exporters remain optional. Diagnostics cap the combined queue category and return observations across queries.

solid_objects.activation.started now fires before the activate() hook. Before, it fired after a successful hook. The new activation.completed event takes that meaning, and activation.failed reports a failed hook. Ruby changes the same events in the paired PR. Move a subscriber that reads activation.started as a finished activation to activation.completed.

Both runtimes now retain JSON results for every delivery mode, reauthorize message reads, raise terminal errors from result reads, and expose those errors as data through outcome reads. Ruby adopts this contract in the paired PR.

Validation

  • New payload and timeout compatibility coverage includes isolated failures, staged work, generated defaults, configured byte boundaries, and activation metadata; the full suite passes.
  • The shared timeout fixture covers every wait reason, activation IDs and generations, private-field exclusion, and unknown activation fields. Ruby runs the same contract with native snake_case inputs.
  • Latest head: pnpm run check, pnpm run format:check, and the full suite: 583 passed, 32 existing environment/adapter skips. Cloudflare suite: 60 passed.
  • Observer checks: before the fix, the callback test failed with promise resolved "[Function]" instead of rejecting, for observe() and for on() separately. Removing the 1,000-observer cap fails its test with expected [Function] to be an instance of RangeError. Removing the close() cleanup fails its test with expected [...] to have a length of 1 but got 2.
  • Shared event contract: before the fix, the lifecycle test failed with activation.started attributes: expected [ 'actorId', 'actorType', …(8) ] to deeply equal [ 'actorId', 'actorType', …(9) ], and the allowlist test failed because no allowlist was exported. Both pass after the fix.
  • Purity reversal: removing the staged-work query check fails the 5 new query tests; removing the observable intent check fails the 5 new observable tests and the 2 replacement tests. Restoring the guards passes.
  • PostgreSQL ordering and migration checks: 3 passed, no skips, including existing and interrupted schema upgrades.
  • Chromium browser suite: 9 passed; Cloudflare suite: 60 passed.
  • Projection reversal: restoring the count-only guard makes both new regressions complete the message instead of raising terminal QueryMutatedState. Restoring the fix passes.
  • JSON reversal: restoring the old serializer fails all six new regressions, including persisted results and byte limits. Restoring the fix passes.
  • Reversal: restoring the old transmit implementation fails both the deterministic ordering regression and the null-arguments fixture. Restoring the fix passes.
  • The shared telemetry query was exercised against SQLite and PostgreSQL. Prior instrumentation reversal failed with private sink error and passed after restoring isolation.

Closes #42.

Add a shared event envelope, bounded actor diagnostics, and safe local observers. Map SQL and Durable Objects lifecycle telemetry without exposing application payloads. Keep exporter and error-logger failures outside durable work.

Refs #42
CI audit reports high-severity WebSocket and TLS advisories for the existing Miniflare pin. Override only Undici 7.29.0 with its patched 7.29.1 release; regenerate the lockfile and verify the audit and Durable Objects suite.
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Adds schema migration and observability infrastructure across runtimes.

The PR appears safe to merge based on this review; no new issue was found in the latest change.

Summary

The PR adds portable telemetry and authorized actor diagnostics, preserves staged transmit order, and strengthens projection and JSON-value handling. The latest commit removes an unnecessary return annotation from the projection regression test.

Reviews (6) · Last reviewed commit: "test: Infer projection return types"

Comment thread src/telemetry.ts Outdated
Comment thread src/runtime.ts Outdated
@greptile-apps

This comment has been minimized.

Include broadcast outbox age and activation message correlation. Keep the\ncombined diagnostics category cap explicit and remove unnecessary broad\ntype annotations from telemetry delivery.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review the current head, 7ede319. The existing summary still references the initial commit.

The flagged explicit unknown annotations and double assertion have been removed, recovery outcome checks are flat, and activation completion now uses the same message correlation fields as activation start. Broadcast delivery also emits outbox age to match Ruby.

All current CI jobs pass. Please reassess the full diff and update the summary for this head.

Persist effect positions within each source message and preserve legacy rows during schema upgrades. Reject null transmit arguments and refresh the parity contract for message reads, administration, and wake-up behavior.
@cardmagic cardmagic changed the title Add portable actor observability and diagnostics Align observability and runtime contracts Sep 30, 2026
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review the latest head after the parity audit fixes. Schema migration 14 persists effect positions so two transmits in one turn preserve staging order; existing rows keep their legacy tie-break. Null transmit arguments are rejected and shared fixtures cover them. The parity ledger now reflects message-result authorization and semantics, administration audit/redrive, reminders, and automatic wake-up. Please review the full diff and update the current-head summary.

Keep JSON keys as own data properties during normalization, including
reserved names, without changing object prototypes. Pin the contract
with shared Ruby fixtures and actor persistence tests.

Correct result retention and reminder limit documentation after the
cross-runtime audit.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review current head d9b6052. The latest fix preserves reserved JSON keys as own data properties without prototype changes, including byte limits and durable actor results. Shared fixtures pin the Ruby/JS contract; all six new regressions fail when the old serializer is restored. The full suite, Chromium, Cloudflare, build, types, and formatting pass. The ledger now records read-only query/projection guards, background result retention, and the existing reminder-name limit difference. Please review the full diff and update the current-head summary.

Compare complete staged work around projections so draining one intent
and staging another cannot bypass the read-only contract. Cover effect
and commit-action replacement with terminal rollback regressions.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review the latest commit c77d15a. Projection guards now compare complete staged work so an observable cannot drain and replace an intent with the same count. Effect and commit-action regressions fail under the prior guard and pass with the fix; 546 tests, static checks, and 60 Cloudflare tests pass.

Comment thread test/read-only-projections.test.ts Outdated
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai Please review the latest commit 8380218. The new observable test now infers its return type, resolving the annotation finding. Static checks and both regression tests pass.

Cover payload isolation, generated snapshot defaults, staged-work
rejection, and configured UTF-8 byte limits. Share timeout telemetry
fixtures with Ruby and assert activation generation metadata.

Update the parity ledger and observability guide for the matching
Ruby projection guards and yielding SQLite busy waits.
A parity audit found portable events with different fields in Ruby and
JavaScript. Only the timeout event had a shared fixture, so no test
found the difference.

compatibility/telemetry-events.json now holds the attribute allowlist
and the exact keys of each core SQL event. Both test suites read the
same file.

- Send ownerId on activation events and activationGeneration on commit
  action events, as Ruby does.
- Send messageId and attempt on reminder.enqueued.
- Send depth: null for a truncated mailbox sample.
- Port the Ruby purity tests for each kind of staged work.
- Correct the dead transmit retry docs, document the diagnostics
  authorization, and remove the unused compatibility/ruby.yml.
Ruby raises ArgumentError when observe or on has no block. JavaScript
accepted a missing onEvent, then logged a TypeError for each event.
Reject the call with TypeError before authorization, as Ruby does.

Port the Ruby tests for the 1,000-observer limit and for observer
removal on close. Each test failed when its guard was removed.

Document both observer errors, the setup sample for each runtime, and
the new activation event order. Remove the delivered observability
section from the roadmap.
Ruby now publishes RBS types for portable events and diagnostics. Name
them next to the matching JavaScript exports so the shared guide stays
the same in both repositories.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Roadmap: portable observability and diagnostics

1 participant