Skip to content

Commit c4f01e6

Browse files
authored
fix: observability spans terminating prematurely on Vercel Edge and Deno Deploy (#701)
1 parent 292c9e4 commit c4f01e6

File tree

5 files changed

+327
-34
lines changed

5 files changed

+327
-34
lines changed

.changeset/slimy-bugs-jam.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@voltagent/serverless-hono": patch
3+
---
4+
5+
fix: observability spans terminating prematurely on Vercel Edge and Deno Deploy
6+
7+
## The Problem
8+
9+
Observability spans were being cut short on Vercel Edge and Deno Deploy runtimes because the `toVercelEdge()` and `toDeno()` adapters didn't implement `waitUntil` support. Unlike `toCloudflareWorker()`, which properly extracted and set up `waitUntil` from the execution context, these adapters would terminate async operations (like span exports) as soon as the response was returned.
10+
11+
This caused the observability pipeline's `FetchTraceExporter` and `FetchLogExporter` to have their export promises cancelled mid-flight, resulting in incomplete or missing observability data.
12+
13+
## The Solution
14+
15+
Refactored all serverless adapters to use a new `withWaitUntil()` helper utility that:
16+
17+
- Extracts `waitUntil` from the runtime context (Cloudflare's `executionCtx`, Vercel's `context`, or Deno's `info`)
18+
- Sets it as `globalThis.___voltagent_wait_until` for the observability exporters to use
19+
- Returns a cleanup function that properly restores previous state
20+
- Handles errors gracefully and supports nested calls
21+
22+
Now all three adapters (`toCloudflareWorker`, `toVercelEdge`, `toDeno`) use the same battle-tested pattern:
23+
24+
```ts
25+
const cleanup = withWaitUntil(context);
26+
try {
27+
return await processRequest(request);
28+
} finally {
29+
cleanup();
30+
}
31+
```
32+
33+
## Impact
34+
35+
- ✅ Observability spans now export successfully on Vercel Edge Runtime
36+
- ✅ Observability spans now export successfully on Deno Deploy
37+
- ✅ Consistent `waitUntil` behavior across all serverless platforms
38+
- ✅ DRY principle: eliminated duplicate code across adapters
39+
- ✅ Comprehensive test coverage with 11 unit tests covering edge cases, nested calls, and error scenarios
40+
41+
## Technical Details
42+
43+
The fix introduces:
44+
45+
- `utils/wait-until-wrapper.ts`: Reusable `withWaitUntil()` helper
46+
- `utils/wait-until-wrapper.spec.ts`: Complete test suite (11/11 passing)
47+
- Updated `toCloudflareWorker()`: Simplified using helper
48+
- **Fixed** `toVercelEdge()`: Now properly supports `waitUntil`
49+
- **Fixed** `toDeno()`: Now properly supports `waitUntil`

packages/serverless-hono/src/serverless-provider.ts

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import type { Hono } from "hono";
33
import { createServerlessApp } from "./app-factory";
44
import type { ServerlessConfig, ServerlessRuntime } from "./types";
55
import { detectServerlessRuntime } from "./utils/runtime-detection";
6-
7-
type VoltAgentGlobal = typeof globalThis & {
8-
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
9-
};
6+
import { withWaitUntil } from "./utils/wait-until-wrapper";
107
export class HonoServerlessProvider implements IServerlessProvider {
118
private readonly deps: ServerProviderDeps;
129
private readonly config?: ServerlessConfig;
@@ -45,54 +42,44 @@ export class HonoServerlessProvider implements IServerlessProvider {
4542
env: Record<string, unknown>,
4643
executionCtx: unknown,
4744
): Promise<Response> => {
48-
const waitUntil =
49-
executionCtx && typeof (executionCtx as any)?.waitUntil === "function"
50-
? (executionCtx as any).waitUntil.bind(executionCtx)
51-
: undefined;
52-
53-
const globals = globalThis as VoltAgentGlobal;
54-
const previousWaitUntil = globals.___voltagent_wait_until;
55-
56-
if (waitUntil) {
57-
globals.___voltagent_wait_until = (promise) => {
58-
try {
59-
waitUntil(promise);
60-
} catch {
61-
void promise;
62-
}
63-
};
64-
}
45+
const cleanup = withWaitUntil(executionCtx as any);
6546

6647
try {
6748
await this.ensureEnvironmentTarget(env);
6849
const app = await this.getApp();
6950
return await app.fetch(request, env as Record<string, unknown>, executionCtx as any);
7051
} finally {
71-
if (waitUntil) {
72-
if (previousWaitUntil) {
73-
globals.___voltagent_wait_until = previousWaitUntil;
74-
} else {
75-
globals.___voltagent_wait_until = undefined;
76-
}
77-
}
52+
cleanup();
7853
}
7954
},
8055
};
8156
}
8257

8358
toVercelEdge(): (request: Request, context?: unknown) => Promise<Response> {
8459
return async (request: Request, context?: unknown) => {
85-
await this.ensureEnvironmentTarget(context as Record<string, unknown> | undefined);
86-
const app = await this.getApp();
87-
return app.fetch(request, context as Record<string, unknown> | undefined);
60+
const cleanup = withWaitUntil(context as any);
61+
62+
try {
63+
await this.ensureEnvironmentTarget(context as Record<string, unknown> | undefined);
64+
const app = await this.getApp();
65+
return await app.fetch(request, context as Record<string, unknown> | undefined);
66+
} finally {
67+
cleanup();
68+
}
8869
};
8970
}
9071

9172
toDeno(): (request: Request, info?: unknown) => Promise<Response> {
9273
return async (request: Request, info?: unknown) => {
93-
await this.ensureEnvironmentTarget(info as Record<string, unknown> | undefined);
94-
const app = await this.getApp();
95-
return app.fetch(request, info as Record<string, unknown> | undefined);
74+
const cleanup = withWaitUntil(info as any);
75+
76+
try {
77+
await this.ensureEnvironmentTarget(info as Record<string, unknown> | undefined);
78+
const app = await this.getApp();
79+
return await app.fetch(request, info as Record<string, unknown> | undefined);
80+
} finally {
81+
cleanup();
82+
}
9683
};
9784
}
9885

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { WaitUntilContext } from "./wait-until-wrapper";
3+
import { withWaitUntil } from "./wait-until-wrapper";
4+
5+
type VoltAgentGlobal = typeof globalThis & {
6+
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
7+
};
8+
9+
describe("withWaitUntil", () => {
10+
let originalWaitUntil: ((promise: Promise<unknown>) => void) | undefined;
11+
12+
beforeEach(() => {
13+
const globals = globalThis as VoltAgentGlobal;
14+
originalWaitUntil = globals.___voltagent_wait_until;
15+
});
16+
17+
afterEach(() => {
18+
const globals = globalThis as VoltAgentGlobal;
19+
globals.___voltagent_wait_until = originalWaitUntil;
20+
});
21+
22+
it("should set global waitUntil when context has waitUntil", () => {
23+
const mockWaitUntil = vi.fn();
24+
const context: WaitUntilContext = { waitUntil: mockWaitUntil };
25+
26+
withWaitUntil(context);
27+
28+
const globals = globalThis as VoltAgentGlobal;
29+
expect(globals.___voltagent_wait_until).toBeDefined();
30+
});
31+
32+
it("should not set global waitUntil when context is undefined", () => {
33+
withWaitUntil(undefined);
34+
35+
const globals = globalThis as VoltAgentGlobal;
36+
expect(globals.___voltagent_wait_until).toBeUndefined();
37+
});
38+
39+
it("should not set global waitUntil when context is null", () => {
40+
withWaitUntil(null);
41+
42+
const globals = globalThis as VoltAgentGlobal;
43+
expect(globals.___voltagent_wait_until).toBeUndefined();
44+
});
45+
46+
it("should not set global waitUntil when context has no waitUntil", () => {
47+
const context = {};
48+
49+
withWaitUntil(context);
50+
51+
const globals = globalThis as VoltAgentGlobal;
52+
expect(globals.___voltagent_wait_until).toBeUndefined();
53+
});
54+
55+
it("should call context.waitUntil when global waitUntil is invoked", () => {
56+
const mockWaitUntil = vi.fn();
57+
const context: WaitUntilContext = { waitUntil: mockWaitUntil };
58+
59+
withWaitUntil(context);
60+
61+
const globals = globalThis as VoltAgentGlobal;
62+
const promise = Promise.resolve();
63+
globals.___voltagent_wait_until?.(promise);
64+
65+
expect(mockWaitUntil).toHaveBeenCalledWith(promise);
66+
});
67+
68+
it("should handle errors from context.waitUntil gracefully", () => {
69+
const mockWaitUntil = vi.fn(() => {
70+
throw new Error("waitUntil error");
71+
});
72+
const context: WaitUntilContext = { waitUntil: mockWaitUntil };
73+
74+
withWaitUntil(context);
75+
76+
const globals = globalThis as VoltAgentGlobal;
77+
const promise = Promise.resolve();
78+
79+
// Should not throw
80+
expect(() => globals.___voltagent_wait_until?.(promise)).not.toThrow();
81+
expect(mockWaitUntil).toHaveBeenCalled();
82+
});
83+
84+
it("should restore previous waitUntil after cleanup", () => {
85+
const previousWaitUntil = vi.fn();
86+
const mockWaitUntil = vi.fn();
87+
const context: WaitUntilContext = { waitUntil: mockWaitUntil };
88+
89+
const globals = globalThis as VoltAgentGlobal;
90+
globals.___voltagent_wait_until = previousWaitUntil;
91+
92+
const cleanup = withWaitUntil(context);
93+
94+
expect(globals.___voltagent_wait_until).not.toBe(previousWaitUntil);
95+
96+
cleanup();
97+
98+
expect(globals.___voltagent_wait_until).toBe(previousWaitUntil);
99+
});
100+
101+
it("should clear global waitUntil after cleanup when no previous value existed", () => {
102+
const mockWaitUntil = vi.fn();
103+
const context: WaitUntilContext = { waitUntil: mockWaitUntil };
104+
105+
const globals = globalThis as VoltAgentGlobal;
106+
globals.___voltagent_wait_until = undefined;
107+
108+
const cleanup = withWaitUntil(context);
109+
110+
expect(globals.___voltagent_wait_until).toBeDefined();
111+
112+
cleanup();
113+
114+
expect(globals.___voltagent_wait_until).toBeUndefined();
115+
});
116+
117+
it("should handle nested calls with proper state restoration", () => {
118+
const outerWaitUntil = vi.fn();
119+
const innerWaitUntil = vi.fn();
120+
121+
const outerContext: WaitUntilContext = { waitUntil: outerWaitUntil };
122+
const innerContext: WaitUntilContext = { waitUntil: innerWaitUntil };
123+
124+
const globals = globalThis as VoltAgentGlobal;
125+
126+
const outerCleanup = withWaitUntil(outerContext);
127+
const outerGlobalWaitUntil = globals.___voltagent_wait_until;
128+
129+
const innerCleanup = withWaitUntil(innerContext);
130+
const innerGlobalWaitUntil = globals.___voltagent_wait_until;
131+
132+
expect(outerGlobalWaitUntil).toBeDefined();
133+
expect(innerGlobalWaitUntil).toBeDefined();
134+
expect(innerGlobalWaitUntil).not.toBe(outerGlobalWaitUntil);
135+
136+
// Call inner global waitUntil
137+
const promise1 = Promise.resolve();
138+
globals.___voltagent_wait_until?.(promise1);
139+
expect(innerWaitUntil).toHaveBeenCalledWith(promise1);
140+
expect(outerWaitUntil).not.toHaveBeenCalled();
141+
142+
// Cleanup inner
143+
innerCleanup();
144+
expect(globals.___voltagent_wait_until).toBe(outerGlobalWaitUntil);
145+
146+
// Call outer global waitUntil
147+
const promise2 = Promise.resolve();
148+
globals.___voltagent_wait_until?.(promise2);
149+
expect(outerWaitUntil).toHaveBeenCalledWith(promise2);
150+
151+
// Cleanup outer
152+
outerCleanup();
153+
expect(globals.___voltagent_wait_until).toBeUndefined();
154+
});
155+
156+
it("should not affect global state when context has no waitUntil", () => {
157+
const globals = globalThis as VoltAgentGlobal;
158+
const previousWaitUntil = vi.fn();
159+
globals.___voltagent_wait_until = previousWaitUntil;
160+
161+
const cleanup = withWaitUntil({});
162+
163+
expect(globals.___voltagent_wait_until).toBe(previousWaitUntil);
164+
165+
cleanup();
166+
167+
expect(globals.___voltagent_wait_until).toBe(previousWaitUntil);
168+
});
169+
170+
it("should handle context with non-function waitUntil", () => {
171+
const context = { waitUntil: "not a function" as any };
172+
173+
const cleanup = withWaitUntil(context);
174+
175+
const globals = globalThis as VoltAgentGlobal;
176+
expect(globals.___voltagent_wait_until).toBeUndefined();
177+
178+
cleanup();
179+
});
180+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
type VoltAgentGlobal = typeof globalThis & {
2+
___voltagent_wait_until?: (promise: Promise<unknown>) => void;
3+
};
4+
5+
/**
6+
* Context that may contain a waitUntil function
7+
*/
8+
export interface WaitUntilContext {
9+
waitUntil?: (promise: Promise<unknown>) => void;
10+
}
11+
12+
/**
13+
* Extracts waitUntil from context and sets it as global for observability
14+
* Returns a cleanup function to restore previous state
15+
*
16+
* @param context - Context object that may contain waitUntil
17+
* @returns Cleanup function to restore previous state
18+
*
19+
* @example
20+
* ```ts
21+
* const cleanup = withWaitUntil(executionCtx);
22+
* try {
23+
* return await processRequest(request);
24+
* } finally {
25+
* cleanup();
26+
* }
27+
* ```
28+
*/
29+
export function withWaitUntil(context?: WaitUntilContext | null): () => void {
30+
const globals = globalThis as VoltAgentGlobal;
31+
const previousWaitUntil = globals.___voltagent_wait_until;
32+
33+
const waitUntil = context?.waitUntil;
34+
35+
if (waitUntil && typeof waitUntil === "function") {
36+
globals.___voltagent_wait_until = (promise) => {
37+
try {
38+
waitUntil(promise);
39+
} catch {
40+
// Silently fail if waitUntil throws
41+
void promise;
42+
}
43+
};
44+
}
45+
46+
// Return cleanup function
47+
return () => {
48+
if (waitUntil) {
49+
if (previousWaitUntil) {
50+
globals.___voltagent_wait_until = previousWaitUntil;
51+
} else {
52+
globals.___voltagent_wait_until = undefined;
53+
}
54+
}
55+
};
56+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
include: ["**/*.spec.ts"],
6+
environment: "node",
7+
coverage: {
8+
provider: "v8",
9+
reporter: ["text", "json", "html"],
10+
include: ["src/**/*.ts"],
11+
exclude: ["src/**/*.d.ts", "src/**/index.ts"],
12+
},
13+
typecheck: {
14+
include: ["**/**/*.spec-d.ts"],
15+
exclude: ["**/**/*.spec.ts"],
16+
},
17+
globals: true,
18+
testTimeout: 10000,
19+
hookTimeout: 10000,
20+
},
21+
});

0 commit comments

Comments
 (0)