Skip to content

Commit fd84019

Browse files
authored
Add per-isolate SSE + OTel memory counters to cloud worker (#1255)
* Add per-isolate SSE + OTel memory counters to cloud worker * Address review: abort-free byte counts, SSE-only wrap guard, no drop/export double-count
1 parent 1c48182 commit fd84019

3 files changed

Lines changed: 373 additions & 14 deletions

File tree

apps/cloud/src/mcp/agent-handler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@executor-js/cloudflare/mcp/do-headers";
1515
import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object";
1616

17+
import { wrapMcpSseResponse } from "../observability/memory-metrics";
1718
import { cloudMcpAuth } from "./auth-provider";
1819
import { McpSessionDOSqlite } from "./session-durable-object";
1920

@@ -158,6 +159,7 @@ export const makeCloudMcpAgentHandler = () => {
158159
},
159160
resource,
160161
);
161-
return serve.fetch(forwarded, env, ctx);
162+
const response = await serve.fetch(forwarded, env, ctx);
163+
return wrapMcpSseResponse(request, env, response);
162164
};
163165
};
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import type { Context } from "@opentelemetry/api";
2+
import type {
3+
ReadableSpan,
4+
Span,
5+
SpanExporter,
6+
SpanProcessor,
7+
} from "@opentelemetry/sdk-trace-base";
8+
9+
const LOG_PREFIX = "[executor:mem-metrics]";
10+
const PERIODIC_EMIT_INTERVAL_MS = 60_000;
11+
const STALLED_WRITE_MS = 30_000;
12+
const EXPORT_SUCCESS_CODE = 0;
13+
export const OTEL_MAX_SPAN_QUEUE_SIZE = 2_048;
14+
15+
type CloseReason = "normal" | "cancel" | "error";
16+
17+
type CfRequestMetadata = {
18+
readonly colo?: string;
19+
};
20+
21+
type VersionMetadataEnv = {
22+
readonly VERSION_METADATA?: WorkerVersionMetadata;
23+
readonly CF_VERSION_METADATA?: WorkerVersionMetadata;
24+
readonly SCRIPT_VERSION?: string;
25+
};
26+
27+
type SseConnection = {
28+
readonly id: number;
29+
readonly startedAt: number;
30+
lastWriteAt: number;
31+
chunks: number;
32+
bytes: number;
33+
closed: boolean;
34+
colo: string;
35+
scriptVersion: string;
36+
};
37+
38+
type AgeBuckets = {
39+
readonly lt1m: number;
40+
readonly m1to5: number;
41+
readonly m5to30: number;
42+
readonly m30to60: number;
43+
readonly gt60m: number;
44+
};
45+
46+
type OtelMetricsSnapshot = {
47+
readonly spansEnded: number;
48+
readonly spansExported: number;
49+
readonly spansDropped: number;
50+
readonly spanExportFailures: number;
51+
readonly forceFlushCalls: number;
52+
readonly forceFlushFailures: number;
53+
readonly forceFlushDurationMsTotal: number;
54+
readonly forceFlushDurationMsLast: number;
55+
readonly maxQueueSize: number;
56+
};
57+
58+
const activeSse = new Map<number, SseConnection>();
59+
60+
let nextConnectionId = 1;
61+
let totalBytesForwarded = 0;
62+
let lastSnapshotEmittedAt = 0;
63+
64+
const otelMetrics = {
65+
spansEnded: 0,
66+
spansExported: 0,
67+
spansDropped: 0,
68+
spanExportFailures: 0,
69+
forceFlushCalls: 0,
70+
forceFlushFailures: 0,
71+
forceFlushDurationMsTotal: 0,
72+
forceFlushDurationMsLast: 0,
73+
};
74+
75+
const requestWithCf = (request: Request): Request & { readonly cf?: CfRequestMetadata } =>
76+
request as Request & { readonly cf?: CfRequestMetadata };
77+
78+
const scriptVersionFromEnv = (env: Env): string => {
79+
const metadataEnv = env as Env & VersionMetadataEnv;
80+
return (
81+
metadataEnv.VERSION_METADATA?.id ??
82+
metadataEnv.VERSION_METADATA?.tag ??
83+
metadataEnv.CF_VERSION_METADATA?.id ??
84+
metadataEnv.CF_VERSION_METADATA?.tag ??
85+
metadataEnv.SCRIPT_VERSION ??
86+
""
87+
);
88+
};
89+
90+
const utf8ByteCounter = new TextEncoder();
91+
92+
const byteLengthOf = (chunk: unknown): number => {
93+
if (typeof chunk === "string") return utf8ByteCounter.encode(chunk).length;
94+
if (chunk instanceof ArrayBuffer) return chunk.byteLength;
95+
if (ArrayBuffer.isView(chunk)) return chunk.byteLength;
96+
return 0;
97+
};
98+
99+
const emptyAgeBuckets = (): AgeBuckets => ({
100+
lt1m: 0,
101+
m1to5: 0,
102+
m5to30: 0,
103+
m30to60: 0,
104+
gt60m: 0,
105+
});
106+
107+
const bucketAge = (buckets: AgeBuckets, ageMs: number): AgeBuckets => {
108+
const next = { ...buckets };
109+
if (ageMs < 60_000) next.lt1m += 1;
110+
else if (ageMs < 5 * 60_000) next.m1to5 += 1;
111+
else if (ageMs < 30 * 60_000) next.m5to30 += 1;
112+
else if (ageMs < 60 * 60_000) next.m30to60 += 1;
113+
else next.gt60m += 1;
114+
return next;
115+
};
116+
117+
const otelSnapshot = (maxQueueSize: number): OtelMetricsSnapshot => ({
118+
...otelMetrics,
119+
maxQueueSize,
120+
});
121+
122+
const snapshot = (now: number, maxQueueSize: number) => {
123+
let oldestConnectionAgeMs = 0;
124+
let stalledConnections = 0;
125+
let ageBuckets = emptyAgeBuckets();
126+
const colos = new Set<string>();
127+
const scriptVersions = new Set<string>();
128+
129+
for (const connection of activeSse.values()) {
130+
const ageMs = now - connection.startedAt;
131+
oldestConnectionAgeMs = Math.max(oldestConnectionAgeMs, ageMs);
132+
if (now - connection.lastWriteAt > STALLED_WRITE_MS) stalledConnections += 1;
133+
ageBuckets = bucketAge(ageBuckets, ageMs);
134+
if (connection.colo) colos.add(connection.colo);
135+
if (connection.scriptVersion) scriptVersions.add(connection.scriptVersion);
136+
}
137+
const sortedColos = Array.from(colos).sort();
138+
const sortedScriptVersions = Array.from(scriptVersions).sort();
139+
140+
return {
141+
activeSseConnections: activeSse.size,
142+
ageBuckets,
143+
oldestConnectionAgeMs,
144+
totalBytesForwarded,
145+
stalledConnections,
146+
stalledWriteThresholdMs: STALLED_WRITE_MS,
147+
colo: sortedColos[0] ?? "",
148+
colos: sortedColos,
149+
scriptVersion: sortedScriptVersions[0] ?? "",
150+
scriptVersions: sortedScriptVersions,
151+
otel: otelSnapshot(maxQueueSize),
152+
};
153+
};
154+
155+
const emitSnapshot = (event: string, now: number, maxQueueSize: number): void => {
156+
lastSnapshotEmittedAt = now;
157+
console.log(
158+
`${LOG_PREFIX} ${JSON.stringify({
159+
event,
160+
...snapshot(now, maxQueueSize),
161+
})}`,
162+
);
163+
};
164+
165+
const maybeEmitPeriodicSnapshot = (now: number, maxQueueSize: number): void => {
166+
if (now - lastSnapshotEmittedAt < PERIODIC_EMIT_INTERVAL_MS) return;
167+
emitSnapshot("snapshot", now, maxQueueSize);
168+
};
169+
170+
const closeConnection = (
171+
connection: SseConnection,
172+
reason: CloseReason,
173+
maxQueueSize: number,
174+
): void => {
175+
if (connection.closed) return;
176+
connection.closed = true;
177+
activeSse.delete(connection.id);
178+
const now = Date.now();
179+
console.log(
180+
`${LOG_PREFIX} ${JSON.stringify({
181+
event: "sse_close",
182+
connectionId: connection.id,
183+
reason,
184+
ageMs: now - connection.startedAt,
185+
bytesForwarded: connection.bytes,
186+
chunksForwarded: connection.chunks,
187+
lastWriteAgeMs: now - connection.lastWriteAt,
188+
colo: connection.colo,
189+
scriptVersion: connection.scriptVersion,
190+
})}`,
191+
);
192+
maybeEmitPeriodicSnapshot(now, maxQueueSize);
193+
};
194+
195+
const sseHeaders = (headers: Headers): Headers => {
196+
const next = new Headers(headers);
197+
next.delete("content-length");
198+
return next;
199+
};
200+
201+
export const wrapMcpSseResponse = (request: Request, env: Env, response: Response): Response => {
202+
if (
203+
request.method !== "GET" ||
204+
response.body === null ||
205+
!(response.headers.get("content-type") ?? "").includes("text/event-stream")
206+
)
207+
return response;
208+
209+
const now = Date.now();
210+
const connection: SseConnection = {
211+
id: nextConnectionId++,
212+
startedAt: now,
213+
lastWriteAt: now,
214+
chunks: 0,
215+
bytes: 0,
216+
closed: false,
217+
colo: requestWithCf(request).cf?.colo ?? "",
218+
scriptVersion: scriptVersionFromEnv(env),
219+
};
220+
activeSse.set(connection.id, connection);
221+
emitSnapshot("sse_open", now, OTEL_MAX_SPAN_QUEUE_SIZE);
222+
223+
const counting = new TransformStream<unknown, unknown>({
224+
transform(chunk, controller) {
225+
const written = byteLengthOf(chunk);
226+
const writeAt = Date.now();
227+
connection.chunks += 1;
228+
connection.bytes += written;
229+
connection.lastWriteAt = writeAt;
230+
totalBytesForwarded += written;
231+
maybeEmitPeriodicSnapshot(writeAt, OTEL_MAX_SPAN_QUEUE_SIZE);
232+
controller.enqueue(chunk);
233+
},
234+
flush() {
235+
closeConnection(connection, "normal", OTEL_MAX_SPAN_QUEUE_SIZE);
236+
},
237+
cancel(reason) {
238+
closeConnection(
239+
connection,
240+
reason === undefined ? "cancel" : "error",
241+
OTEL_MAX_SPAN_QUEUE_SIZE,
242+
);
243+
},
244+
});
245+
246+
const body = response.body.pipeThrough(counting);
247+
return new Response(body, {
248+
status: response.status,
249+
statusText: response.statusText,
250+
headers: sseHeaders(response.headers),
251+
});
252+
};
253+
254+
export const recordForceFlush = (durationMs: number, failed: boolean): void => {
255+
otelMetrics.forceFlushCalls += 1;
256+
otelMetrics.forceFlushDurationMsLast = durationMs;
257+
otelMetrics.forceFlushDurationMsTotal += durationMs;
258+
if (failed) otelMetrics.forceFlushFailures += 1;
259+
};
260+
261+
export class CountingSpanExporter implements SpanExporter {
262+
constructor(
263+
private readonly inner: SpanExporter,
264+
private readonly onExportAttempt: (spans: number) => void,
265+
) {}
266+
267+
export(spans: ReadableSpan[], resultCallback: Parameters<SpanExporter["export"]>[1]): void {
268+
this.onExportAttempt(spans.length);
269+
this.inner.export(spans, (result) => {
270+
if (result.code === EXPORT_SUCCESS_CODE) {
271+
otelMetrics.spansExported += spans.length;
272+
} else {
273+
otelMetrics.spansDropped += spans.length;
274+
otelMetrics.spanExportFailures += 1;
275+
}
276+
resultCallback(result);
277+
});
278+
}
279+
280+
shutdown(): Promise<void> {
281+
return this.inner.shutdown();
282+
}
283+
284+
forceFlush(): Promise<void> {
285+
return this.inner.forceFlush?.() ?? Promise.resolve();
286+
}
287+
}
288+
289+
export class CountingSpanProcessor implements SpanProcessor {
290+
private estimatedQueuedSpans = 0;
291+
292+
constructor(
293+
private readonly inner: SpanProcessor,
294+
private readonly maxQueueSize: number,
295+
) {}
296+
297+
forceFlush(): Promise<void> {
298+
return this.inner.forceFlush();
299+
}
300+
301+
onStart(span: Span, parentContext: Context): void {
302+
this.inner.onStart(span, parentContext);
303+
}
304+
305+
onEnding(span: Span): void {
306+
this.inner.onEnding?.(span);
307+
}
308+
309+
onEnd(span: ReadableSpan): void {
310+
otelMetrics.spansEnded += 1;
311+
if (this.estimatedQueuedSpans >= this.maxQueueSize) {
312+
// At the bound the inner processor would drop this span anyway; drop it
313+
// here instead of forwarding, so a dropped span is never also counted as
314+
// exported (which would double-count and inflate the drop signal).
315+
otelMetrics.spansDropped += 1;
316+
return;
317+
}
318+
this.estimatedQueuedSpans += 1;
319+
this.inner.onEnd(span);
320+
}
321+
322+
shutdown(): Promise<void> {
323+
return this.inner.shutdown();
324+
}
325+
326+
recordExportAttempt(spans: number): void {
327+
this.estimatedQueuedSpans = Math.max(0, this.estimatedQueuedSpans - spans);
328+
}
329+
}

0 commit comments

Comments
 (0)