Skip to content

instrumentAgentWithSentry: @callable() methods are lost after a constructor throws once #25728

Open

Description

@nuntz

Package: @sentry/cloudflare 11.4.0, with agents 0.30.1, Workers compatibility date 2026-10-01

What happens

If an Agent's constructor throws once (in our case the Durable Objects free tier's rows-read limit during storage initialization), the next construction of the same class loses its @callable() methods. RPC calls from a connected client then fail with Method callTool is not callable.

Why

In constructInstrumentedDurableObject (build/*/durableobject.js), methodsBeforeConstruction is resolved from the prototype on every construction until frameworkManagedMethodsCache holds an entry for that prototype. That entry is only written after Reflect.construct returns. When the constructor throws, the Agents SDK has already replaced the prototype's methods with its wrapped versions, but no cache entry is written. On the next construction, Sentry snapshots the wrapped methods as the originals, so it no longer recognizes them as framework-managed, and the callable registration is lost.

Reproduction (vitest-pool-workers)

The test needs a Durable Object binding named MyAgent in the Worker's test config, bound to any Agent class; it only borrows that object's state, and the test's own RecoveryAgent does the rest.

import { runInDurableObject } from "cloudflare:test";
import { env } from "cloudflare:workers";
import * as Sentry from "@sentry/cloudflare";
import { Agent, callable } from "agents";
import { expect, test } from "vitest";

test("callable methods survive construction retry after a constructor failure", async () => {
  await runInDurableObject(env.MyAgent.getByName(crypto.randomUUID()), async (_obj, state) => {
    let fail = true;
    class RecoveryAgent extends Agent<Env> {
      constructor(ctx: DurableObjectState, bindings: Env) {
        super(ctx, bindings);
        if (fail) {
          fail = false;
          throw new Error("Exceeded allowed rows read in Durable Objects free tier.");
        }
      }
      @callable()
      async read() {
        return "ready";
      }
    }
    const Wrapped = Sentry.instrumentAgentWithSentry(() => ({ dsn: "" }), RecoveryAgent);
    expect(() => new Wrapped(state, env)).toThrow("Exceeded allowed rows read");
    const recovered = new Wrapped(state, env);
    expect(recovered.getCallableMethods().has("read")).toBe(true); // fails on 11.4.0
  });
});

Workaround we are running

Cache the pre-construction snapshot per prototype, so a retry after a failure reuses the first snapshot:

-  const methodsBeforeConstruction = cachedFrameworkManagedMethods ? void 0 : resolvePrototypeMethods(prototype);
+  let methodsBeforeConstruction = methodsBeforeConstructionCache.get(prototype);
+  if (!cachedFrameworkManagedMethods && !methodsBeforeConstruction) {
+    methodsBeforeConstruction = resolvePrototypeMethods(prototype);
+    methodsBeforeConstructionCache.set(prototype, methodsBeforeConstruction);
+  }
 ...
 const frameworkManagedMethodsCache = /* @__PURE__ */ new WeakMap();
+const methodsBeforeConstructionCache = /* @__PURE__ */ new WeakMap();

Happy to open a PR with this and the test if the approach looks right.

Activity

  1. linear-code commented on Oct 24, 2026

    @linear-code
  2. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on Oct 24, 2026
  3. logaretm commented on Oct 26, 2026

    @logaretm
    Member

    Thanks for the report, feel free to open a PR!

  4. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions