Skip to content

Commit df9c083

Browse files
committed
fix: Reject observers without a callback
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.
1 parent 95433f0 commit df9c083

7 files changed

Lines changed: 95 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## Unreleased
44

5+
- `solid_objects.activation.started` now fires before the actor's `activate()`
6+
hook. Before, it fired after a successful hook. The new
7+
`solid_objects.activation.completed` event takes that meaning, and
8+
`solid_objects.activation.failed` reports a failed hook. Ruby changes the same
9+
events. Move a subscriber that reads `activation.started` as a finished
10+
activation to `activation.completed`.
11+
- Reference `observe()` and `on()` reject a missing `onEvent` callback with
12+
`TypeError` before authorization, as Ruby raises `ArgumentError` without a
13+
block. Tests pin the 1,000-observer limit and observer removal on `close()`.
14+
The roadmap no longer lists portable observability as future work.
15+
516
- Add the shared telemetry contract `compatibility/telemetry-events.json`. Tests
617
compare the attribute allowlist and the attribute keys of each core SQL event
718
with Ruby. Activation events carry `ownerId`, commit action events carry

‎docs/api.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,9 @@ Mounting, authorization actions, CSRF behavior, pages, and extensions are in
10731073
values. `EventObserver` is a provider-free event callback. An actor reference offers
10741074
`observe({ onEvent, authorizationContext })`, `on(name, { onEvent, authorizationContext })`,
10751075
and `diagnostics(options)`. Both observer methods resolve to an unsubscribe function.
1076+
They reject a missing `onEvent` with `TypeError` before authorization. A runtime
1077+
accepts at most 1,000 local observers; the next one rejects with `RangeError`.
1078+
`close()` removes every local observer.
10761079
`DiagnosticOptions` accepts `authorizationContext` and a `limit` from 1 to 100.
10771080
`ActorDiagnostics` holds five bounded `DiagnosticSummary` values, with `sampled`,
10781081
`truncated`, and `oldestAgeMilliseconds`. See [observability](observability.md).

‎docs/observability.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ and the Durable Objects host. Existing JavaScript `name`, `occurredAt`, and
66
`attributes` fields remain available. Ruby's Active Support notifications remain
77
available with their existing snake_case payloads.
88

9+
```ts
10+
import { configure } from "solid-objects"
11+
12+
const runtime = configure({
13+
...applicationConfiguration,
14+
instrumentation: (event) => {
15+
console.info(JSON.stringify(event))
16+
},
17+
})
18+
```
19+
20+
JavaScript passes `instrumentation` to `configure()`. Ruby sets
21+
`configuration.instrumentation` inside `SolidObjects.configure`.
22+
923
## Schema version 1
1024

1125
Every event has `schemaVersion`, `name` (prefixed with `solid_objects.`),
@@ -121,10 +135,16 @@ calling the returned proc. `on` filters one event name, such as `message.retry`.
121135
Observers receive only this actor's events in the current runtime/process; they
122136
are not subscriptions to workers on other hosts. Dispose them when the caller's
123137
session ends or authorization is revoked. Each runtime or process accepts at most
124-
1,000 local observers. An observer needs a callback or a block.
125-
For remote Durable Objects, configure `instrumentation` on the actor host and
126-
filter by actor identity there; process-local reference observers raise
127-
`UnsupportedCapability`. Remote `reference.diagnostics` is supported.
138+
1,000 local observers. More observers raise `RangeError` in JavaScript and
139+
`ArgumentError` in Ruby. An observer needs a callback or a block. JavaScript
140+
rejects a missing `onEvent` with `TypeError`, and Ruby raises `ArgumentError`
141+
without a block. Both checks run before authorization.
142+
143+
The JavaScript Durable Objects host does not support process-local reference
144+
observers. On that host, `observe` and `on` raise `UnsupportedCapability`, and
145+
remote `reference.diagnostics` works. To observe a remote actor, configure
146+
`instrumentation` on the actor host and filter the events by actor identity.
147+
Ruby has no Durable Objects host.
128148

129149
Both APIs default to denied. Set `authorizeAdministration` / `authorize_administration`
130150
to allow action `observe` or `inspect`, resource `actor_diagnostics`, and resource ID

‎docs/parity.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Ruby publishes RBS contracts with native Ruby field names; this does not change
8989
| CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. |
9090
| Operator dashboard | Native | Fetch and Node/Connect mounting, authorized views and actions, CSRF, filtering, paging, charts, and extensions match the Rack dashboard. Both dashboards expose individual retries and pause/resume; bulk redrive is available through the administration APIs. Pause does not interrupt an in-flight turn. |
9191
| Bulk redrive and administration audit | Native | Message, effect, and broadcast scopes support idempotent retry and durable, bounded, cancellable redrive. Retry and task transitions persist administration identity in audit rows. |
92-
| Structured instrumentation | Native | Versioned portable events, metric samples, isolated observers, and bounded authorized actor diagnostics match Ruby. Both suites check SQL event attributes against `compatibility/telemetry-events.json`; only JS activation events add the message fields of their turn. Both runtimes log exporter and observer failures and accept at most 1,000 local observers. Timeout events share wait reasons, activation owner IDs, and string generations; see [observability](observability.md). Durable Objects uses host instrumentation and remote diagnostics, sends fewer event attributes, and does not send `sync.timeout`. |
92+
| Structured instrumentation | Native | Versioned events, metric samples, isolated observers, and bounded authorized diagnostics match Ruby. Both test suites check SQL event attributes against `compatibility/telemetry-events.json`; only JS activation events add their turn's message fields. Both runtimes log exporter and observer failures, require observer callbacks, and cap local observers at 1,000. Timeout events share wait reasons, activation owner IDs, and string generations; see [observability](observability.md). Durable Objects uses host instrumentation and remote diagnostics, sends fewer attributes, and does not send `sync.timeout`. |
9393
| Large committed state warning | Native | `warnStateBytes` reports one `solid_objects.state.large` event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state, and it reports after the commit. The Ruby gem carries the same event and the same 5 MB hard default from `0.14.3`, as `warn_state_bytes`. Its threshold defaults to 64 KB rather than 128 KB, because its measured curve falls sooner: it keeps 55% of its empty-state throughput at 13 KB, where this package keeps 98% at 16 KB. |
9494
| Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. |
9595

‎roadmap.md‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,3 @@ letters, reminders, outboxes, incarnation, and revision. Fleet-wide operations
4444
remain optional where a backend cannot provide them.
4545

4646
[Issue #41: portable actor administration](https://github.com/cardmagic/solid-objects-js/issues/41)
47-
48-
## Portable observability and diagnostics
49-
50-
Define common structured events, metrics, and diagnostic views for activation
51-
duration, mailbox depth, retries, dead letters, reminder lateness, outbox age,
52-
recovery failures, and realtime subscriptions without leaking provider APIs.
53-
54-
[Issue #42: portable observability and diagnostics](https://github.com/cardmagic/solid-objects-js/issues/42)

‎src/reference.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,20 +242,22 @@ export class ActorReferenceCore<ActorType extends Actor> {
242242
return this.runtime.diagnostics(this, options)
243243
}
244244

245-
observe(options: {
245+
async observe(options: {
246246
onEvent: EventObserver
247247
authorizationContext?: SnapshotOptions["authorizationContext"]
248248
}): Promise<() => void> {
249+
assertObserver(options.onEvent)
249250
return this.runtime.observe(this, options)
250251
}
251252

252-
on(
253+
async on(
253254
name: string,
254255
options: {
255256
onEvent: EventObserver
256257
authorizationContext?: SnapshotOptions["authorizationContext"]
257258
},
258259
): Promise<() => void> {
260+
assertObserver(options.onEvent)
259261
return this.observe({
260262
...options,
261263
onEvent: (event) => {
@@ -391,6 +393,12 @@ function createMessageSender<ActorType extends Actor>(
391393
}) as ActorMessageSender<ActorType>
392394
}
393395

396+
function assertObserver(onEvent: unknown): void {
397+
if (typeof onEvent !== "function") {
398+
throw new TypeError("an actor observer requires an onEvent callback")
399+
}
400+
}
401+
394402
function assertOperation(operations: ReadonlySet<string>, operation: string): void {
395403
if (!operations.has(operation)) {
396404
throw new UnknownOperation(`unknown operation ${JSON.stringify(operation)}`)

‎test/instrumentation.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,52 @@ describe("structured instrumentation", () => {
338338
expect(JSON.stringify(events)).not.toContain("reminder-private")
339339
})
340340

341+
it("rejects observers without an onEvent callback", async () => {
342+
runtime = configuredRuntime()
343+
await runtime.install()
344+
const reference = InstrumentedActor.ref("callbackless")
345+
346+
await expect(reference.observe({} as never)).rejects.toThrow(TypeError)
347+
await expect(reference.on("message.completed", {} as never)).rejects.toThrow(TypeError)
348+
})
349+
350+
it("accepts at most 1000 local observers", async () => {
351+
runtime = configuredRuntime()
352+
await runtime.install()
353+
const reference = InstrumentedActor.ref("observer-limit")
354+
const stops = await Promise.all(
355+
Array.from({ length: 1000 }, () => reference.observe({ onEvent: () => {} })),
356+
)
357+
358+
const error = await reference.observe({ onEvent: () => {} }).catch((caught: unknown) => caught)
359+
360+
expect(error).toBeInstanceOf(RangeError)
361+
expect(error).toHaveProperty("message", "at most 1000 local observers may be registered")
362+
stops.pop()?.()
363+
stops.push(await reference.observe({ onEvent: () => {} }))
364+
for (const stop of stops) stop()
365+
})
366+
367+
it("removes local observers when the runtime closes", async () => {
368+
runtime = configuredRuntime()
369+
await runtime.install()
370+
const closed = runtime
371+
const identity = { actorType: InstrumentedActor.actorType, actorId: "closed-observer" }
372+
const events: InstrumentationEvent[] = []
373+
await InstrumentedActor.ref(identity.actorId).observe({
374+
onEvent: (event) => {
375+
events.push(event)
376+
},
377+
})
378+
closed.emitInstrumentation("custom", identity)
379+
380+
await closed.close()
381+
runtime = undefined
382+
closed.emitInstrumentation("custom", identity)
383+
384+
expect(events).toHaveLength(1)
385+
})
386+
341387
it("isolates asynchronous sinks and rejects unknown metadata", async () => {
342388
const events: InstrumentationEvent[] = []
343389
runtime = configuredRuntime({

0 commit comments

Comments
 (0)