feat(node): Accept a waitUntil method for serverless environments#3187
feat(node): Accept a waitUntil method for serverless environments#3187dustinbyrne merged 2 commits intomainfrom
waitUntil method for serverless environments#3187Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
If the constructor options include a `waitUntil` method, it will be invoked and debounced automatically on capture calls. This ensures that no data is lost when the serverless runtime shuts down.
5203a2c to
9003106
Compare
|
Size Change: +4.99 kB (+0.07%) Total Size: 6.71 MB
ℹ️ View Unchanged
|
| override async flush(): Promise<void> { | ||
| const flushPromise = super.flush() | ||
| const waitUntil = this.options.waitUntil | ||
| // Only register when no debounce promise is already keeping runtime alive | ||
| if (waitUntil && !this._waitUntilCycle) { | ||
| try { | ||
| waitUntil(flushPromise.catch(() => {})) | ||
| } catch { | ||
| // waitUntil may throw outside request context | ||
| } | ||
| } | ||
| return flushPromise | ||
| } |
There was a problem hiding this comment.
Extra waitUntil registration during shutdown()
The flush() override guards against redundant waitUntil registrations with !this._waitUntilCycle, but this guard is defeated during shutdown(). Here's the sequence:
_shutdown()calls_consumeWaitUntilCycle(), which synchronously sets_waitUntilCycle = undefined.super._shutdown()then callsawait this.flush()(dynamic dispatch → the overridden method).- The overridden
flush()sees!this._waitUntilCycle === trueand callswaitUntil(flushPromise.catch(() => {}))— registering a secondwaitUntilpromise unexpectedly during shutdown.
On platforms where waitUntil is strictly request-scoped (e.g. CloudFlare Workers), this second call may throw (caught by the try/catch, so not fatal). On Vercel it's harmless but semantically unexpected. The existing tests don't assert on the number of waitUntil calls after shutdown(), so this is undetected.
A minimal fix is to set a flag (e.g. _isShuttingDown) before calling super._shutdown() and skip the waitUntil registration in flush() when that flag is set:
override async flush(): Promise<void> {
const flushPromise = super.flush()
const waitUntil = this.options.waitUntil
if (waitUntil && !this._waitUntilCycle && !this._isShuttingDown) {
try {
waitUntil(flushPromise.catch(() => {}))
} catch {
// waitUntil may throw outside request context
}
}
return flushPromise
}
There was a problem hiding this comment.
I'd considered this but I'm not sure it matters. We're talking about an extra call to waitUntil which resolves at the same time as the shutdown flush. Additionally, in a serverless environment, we largely don't even have control over when we're shutting down (outside of waitUntil(), after(), i.e., where are we calling shutdown()?). The runtime can just terminate out from under us when it's not processing requests.
ioannisj
left a comment
There was a problem hiding this comment.
Very low context approve, but code seems reasonable to me.
One thing though, if I understand this correctly, is that .capture() needs to be called before returning a response. If that's the case we'll need to document this well
|
Thanks for the review! As for when |
Problem
In serverless environments, the runtime can terminate immediately after the response is sent. This can occur before PostHog has a chance to flush queued events. This means events are silently dropped unless the user manually calls
await posthog.flush()at the end of every request handler, which is easy to forget and adds boilerplate.Serverless platforms provide a
waitUntil(promise)API that extends the function's lifetime until the given promise resolves, but PostHog's Node SDK had no way to hook into it.Changes
Adds a new
waitUntiloption (pluswaitUntilDebounceMsandwaitUntilMaxWaitMstuning knobs) to the Node SDK that automatically keeps the serverless runtime alive until events are flushed.How it works:
capture()call, a sentinel promise is registered viawaitUntil()to keep the runtime alive.flushAt- andflushInterval-triggered flushes are also covered — the sentinel promise keeps the runtime alive for those too.shutdown()cancels any pending debounce and resolves the sentinel after flushing.waitUntil(e.g., called outside request context) are caught and handled gracefully.Example usage:
Note that this PR does not implement support for request-scoped methods where the PostHog client outlives the request (e.g., CloudFlare's
ctx.waitUntil).Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file