Skip to content

[cloudflare] scheduled handler: raw waitUntil(flushAndDispose(client)) fails every cron invocation with silent outcome=exception #21950

Description

@aabrius

Is there an existing issue for this?

How do you use Sentry?

Sentry Saas (sentry.io)

Which SDK are you using?

@sentry/cloudflare

SDK Version

10.53.1

Framework Version

Cloudflare Workers (Hono app, withSentry wrapper), scheduled handler via cron trigger

Link to Sentry event

(none — the failure mode is precisely that no event and no log is produced)

Reproduction Example/SDK Setup

export default Sentry.withSentry(
  (env) => ({ dsn: env.SENTRY_DSN, release: env.RELEASE, tracesSampleRate: 0 }),
  {
    fetch: app.fetch,
    scheduled: myScheduledHandler, // ← every tick reports outcome=exception
  } satisfies ExportedHandler<Bindings>,
);

Steps to Reproduce

We could not build a minimal repro — wrangler dev and remote preview never exhibit it. It only manifested in the production isolate, with concurrent fetch traffic hitting the same Worker. Filing anyway because the code path is visible by inspection and our elimination evidence is strong; happy to run instrumented builds if it helps.

  1. Worker wrapped in withSentry with both fetch and scheduled handlers, cron trigger every minute.
  2. In production, every single cron tick reports outcome: exception in the Cloudflare dashboard and in wrangler tail --format json.
  3. Nothing is thrown from user code: no log lines, no Sentry event, no exception payload in the tail JSON — the handler body demonstrably runs to completion (its side effects are all applied).

Expected Result

A failure inside the SDK's own post-handler flush must not fail the invocation. wrapScheduledHandler should never let its housekeeping reject the waitUntil promise:

// instrumentations/worker/instrumentScheduled.js
} finally {
  waitUntil(flushAndDispose(client).catch(() => {
    DEBUG_BUILD && debug.warn('Failed to flush/dispose in scheduled handler');
  }));
}

(Or make flushAndDispose itself never-reject — every wrapper call site passes it raw: instrumentScheduled.js, instrumentQueue.js, instrumentEmail.js, instrumentTail.js, and the five waitUntil?.(flushAndDispose(client)) sites in request.js.)

Actual Result

In @sentry/cloudflare@10.53.1, wrapScheduledHandler does:

} finally {
  waitUntil(flushAndDispose(client));
}

with

async function flushAndDispose(client, timeout = 2000) {
  ...
  await client.flush(timeout);
  client.dispose();
}

On Workers, a rejected promise passed to ctx.waitUntil fails the whole invocation — the runtime records outcome: exception even though the handler returned normally. For a fetch handler this is mostly invisible (the response already went out); for a cron it flips every tick to "failed" in the dashboard, silently: the rejection happens after user code, so nothing is loggable from the app and the SDK (whose transport just failed / whose client is mid-dispose) can't report its own demise.

Elimination evidence from our production incident:

  • Every cron tick = outcome: exception, zero logs, zero exceptions in tail JSON, handler side effects all present.
  • Three consecutive releases added progressively more defensive guards inside our handler (whole-body try/catch, guarded resource teardown so nothing user-side could reject) — no effect whatsoever.
  • Moving scheduled outside withSentry (keeping our own direct envelope delivery for errors) fixed it instantly: same handler code, outcome: ok on every tick since, across weeks.

We did not manage to capture the underlying rejection value (by nature it never surfaces anywhere we can hook). Plausible candidates are client.flush() rejecting when the isolate's subrequest is canceled at teardown, or dispose() throwing on an already-disposed client under concurrent invocations sharing the isolate — possibly related to the client-lifetime issues in #20533 and #19589, but the failure mode here is different: the invocation itself is marked failed by the raw waitUntil.

Our current workaround is keeping the scheduled handler unwrapped, which means losing faas.cron spans and automatic capture for crons — we'd love to re-wrap once the flush is guarded.

Metadata

Metadata

Assignees

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions