From cd8ca793fc161b1ba5c2eb1e6db35fb930b9dfa7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:41:27 -0700 Subject: [PATCH] feat(api): add PostHog exception capture to the hosted ORB Worker path Adds src/api/worker-posthog.ts, a parallel PostHog error-tracking sink for the hosted Worker alongside the existing @sentry/hono/cloudflare middleware. Uses posthog-node's own captureException() (already bundled here for src/mcp/telemetry.ts's MCP usage events) rather than a hand-built raw $exception payload -- PostHog's own docs warn that a manually constructed exception event's strict schema "fails in the vast majority of cases", and their Cloudflare Workers guide recommends installing posthog-node itself with flushAt:1/flushInterval:0 for exactly this edge lifecycle. Wired via Hono's documented c.error check after next() (not a try/catch): Hono's own default errorHandler converts a thrown exception into a response several dispatch levels below an outer middleware's next() call, so a plain try/catch there never sees it. WORKER_POSTHOG_API_KEY/HOST/ENVIRONMENT are separate from the dual- purpose POSTHOG_API_KEY (MCP telemetry + self-host #8287), mirroring WORKER_SENTRY_DSN's separation from self-host's SENTRY_DSN for the same cross-wire-avoidance reason. Redaction reuses src/selfhost/redaction- scrub.ts's primitives. Parallel-run alongside Sentry per the epic's gated decommission plan (#8298). Closes #8288 --- src/api/routes.ts | 7 + src/api/worker-posthog.ts | 159 +++++++++++++++++++ src/env.d.ts | 14 ++ test/unit/worker-posthog.test.ts | 237 ++++++++++++++++++++++++++++ test/workers/worker-runtime.test.ts | 5 + 5 files changed, 422 insertions(+) create mode 100644 src/api/worker-posthog.ts create mode 100644 test/unit/worker-posthog.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index aff32e9d71..0afcd172a1 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,5 +1,6 @@ import { Hono, type Context } from "hono"; import { sentry } from "@sentry/hono/cloudflare"; +import { createWorkerPostHogErrorMiddleware } from "./worker-posthog"; import { z } from "zod"; import { parsePositiveInt } from "../utils/json"; import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence"; @@ -1155,6 +1156,12 @@ export function createApp() { * itself has its own direct Node-side (false) and real-isolate (true) tests. */ if (isCloudflareWorkerRuntime()) { app.use(sentry(app, (env) => ({ dsn: env.WORKER_SENTRY_DSN, environment: env.WORKER_SENTRY_ENVIRONMENT ?? "production" }))); + // PostHog parallel sink (#8288, epic #8286): registered right after Sentry so it still wraps every route + // below. Independent of Sentry's own middleware -- both observe the same shared Context.error Hono sets + // once a downstream handler throws (see createWorkerPostHogErrorMiddleware's own doc comment for why this + // is a c.error check, not a try/catch), so ordering between the two doesn't matter. No-ops completely when + // WORKER_POSTHOG_API_KEY is unset -- byte-identical request handling until configured. + app.use(createWorkerPostHogErrorMiddleware()); } /* v8 ignore stop */ app.use("*", async (c, next) => { diff --git a/src/api/worker-posthog.ts b/src/api/worker-posthog.ts new file mode 100644 index 0000000000..55a9c39dca --- /dev/null +++ b/src/api/worker-posthog.ts @@ -0,0 +1,159 @@ +// Hosted ORB Worker PostHog error tracking (#8288, epic #8286). Parallel-run alongside the existing +// @sentry/hono/cloudflare middleware in routes.ts -- both active simultaneously when configured; Sentry is +// only removed once the gated decommission issue (#8298) says so, exactly like #8287's self-host swap. +// +// Uses posthog-node's own documented captureException(), NOT a hand-built $exception payload. #8288 originally +// scoped this as a raw-HTTP capture (mirroring JSONbored/metagraphed#7777's usage-telemetry.ts, which really +// does avoid posthog-node entirely for bundle-size reasons) -- corrected after checking PostHog's own docs: +// they explicitly warn a hand-constructed $exception event "fails in the vast majority of cases because the +// exception event schema is strict", and PostHog's own Cloudflare Workers guide recommends installing +// posthog-node itself with flushAt:1/flushInterval:0 for exactly this edge lifecycle. This Worker already +// bundles posthog-node for src/mcp/telemetry.ts's MCP usage events (#6235), so reusing it here costs nothing +// new in the bundle either -- unlike metagraphed's situation, which the original scoping was based on. +// +// Ephemeral-client-per-call, mirroring src/mcp/telemetry.ts's identical choice for the identical reason: a +// Workers isolate can be torn down between requests, so there's no safe place to hold a long-lived batching +// client the way self-host's posthog.ts does. flushAt:1/flushInterval:0 sends immediately; the caller still +// awaits client.flush() before the returned promise resolves, so scheduling it via ctx.waitUntil (not an +// inline await) keeps a slow flush from delaying the actual HTTP response. +import type { Context } from "hono"; +import { loadNodeHasher, scrubRecord, scrubString } from "../selfhost/redaction-scrub"; + +type PostHogNs = typeof import("posthog-node"); +type PostHogClient = InstanceType; +type PostHogEventMessage = import("posthog-node").EventMessage; + +/** PostHog US-cloud ingestion host, matching src/mcp/telemetry.ts's and src/selfhost/posthog.ts's default. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +/** No per-user identity is tracked (operational error events, not user analytics) -- every event shares one + * anonymous, constant distinct id, mirroring src/selfhost/posthog.ts's POSTHOG_DISTINCT_ID choice. */ +const WORKER_ERROR_DISTINCT_ID = "loopover-worker"; + +let hasherLoaded = false; + +/** The env slice this module reads. Deliberately NAMED DIFFERENTLY from the dual-purpose POSTHOG_API_KEY/ + * POSTHOG_HOST (MCP telemetry #6228/#6235 + self-host error tracking #8287), for the same reason + * WORKER_SENTRY_DSN is named differently from self-host's SENTRY_DSN (see that var's doc comment): this + * Worker's fetch handler is the SAME one self-host's server.ts calls by synthesizing a Worker-shaped env from + * process.env, so reusing POSTHOG_API_KEY here would silently activate Worker-path exception capture inside a + * self-hoster's own Node process the moment they set POSTHOG_API_KEY for #8287's self-host sink -- an + * unrelated, unintended cross-wire. A second, independent reason on top of the Sentry precedent: keeping this + * separate from POSTHOG_API_KEY also lets an operator toggle Worker-level exception capture without touching + * MCP tool-call telemetry, or vice versa. Opt-in like every other Sentry/PostHog var in this codebase: a + * complete no-op until BOTH isCloudflareWorkerRuntime() AND this is set. Inject as a wrangler secret. */ +export type WorkerPostHogEnv = Pick; + +function trimmedOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** True when this deployment has a non-empty WORKER_POSTHOG_API_KEY configured. Callers still also gate on + * isCloudflareWorkerRuntime() -- this function alone doesn't imply the Worker-isolate check. */ +export function isWorkerPostHogConfigured(env: WorkerPostHogEnv): boolean { + return trimmedOrUndefined(env.WORKER_POSTHOG_API_KEY) !== undefined; +} + +/** before_send scrubber -- redact anything token/secret-like before an event leaves the box, applied to every + * outgoing event including the ones captureException() builds internally (exception message/stacktrace can + * legitimately contain an interpolated secret from an upstream error string). Reuses the exact same pure + * primitives src/selfhost/posthog.ts's scrubPostHogEvent does -- one security-critical scrub implementation. */ +export function scrubWorkerPostHogEvent(event: PostHogEventMessage | null): PostHogEventMessage | null { + if (!event) return event; + try { + if (event.properties) scrubRecord(event.properties, 0); + if (typeof event.event === "string") event.event = scrubString(event.event); + } catch { + return null; + } + return event; +} + +/** Build a fresh, ephemeral PostHog client for one capture call. Returns undefined when unconfigured. */ +async function buildClient(env: WorkerPostHogEnv): Promise { + const apiKey = trimmedOrUndefined(env.WORKER_POSTHOG_API_KEY); + if (!apiKey) return undefined; + if (!hasherLoaded) { + hasherLoaded = true; + await loadNodeHasher(); + } + const { PostHog } = await import("posthog-node"); + const host = trimmedOrUndefined(env.WORKER_POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; + return new PostHog(apiKey, { + host, + // Required for an edge/per-request lifecycle (PostHog's own Cloudflare Workers guidance): batched data is + // sent asynchronously and the isolate can be torn down before it lands, causing silent event loss. + flushAt: 1, + flushInterval: 0, + before_send: scrubWorkerPostHogEvent, + }); +} + +/** The allowlisted request context attached to a captured error -- deliberately NOT the raw Hono Context or + * request object (which could carry headers/cookies/bodies), matching #8288's "typed-allowlist" redaction + * option: only these two scalars are ever read off the request. */ +export interface WorkerErrorRequestContext { + path: string; + method: string; +} + +/** Capture one exception from the hosted Worker path. Never throws -- a PostHog init/capture/flush failure + * degrades to recording nothing, matching every other capture* function in this codebase's identical + * best-effort guarantee. Resolves once the event has actually been flushed (or given up on), so the caller + * should schedule this via ctx.waitUntil rather than await it inline on the request's hot path. */ +export async function capturePostHogWorkerError(env: WorkerPostHogEnv, error: unknown, context: WorkerErrorRequestContext): Promise { + try { + const client = await buildClient(env); + if (!client) return; + const err = error instanceof Error ? error : new Error(String(error)); + client.captureException(err, WORKER_ERROR_DISTINCT_ID, { + environment: trimmedOrUndefined(env.WORKER_POSTHOG_ENVIRONMENT) ?? "production", + request_path: scrubString(context.path), + request_method: context.method, + }); + await client.flush(); + } catch { + // Best-effort: telemetry must never surface into the actual request path. + } +} + +/** Resolve the real Workers ExecutionContext off a Hono Context, falling back to a no-op when it isn't + * available (self-host's server.ts calls this same Worker's fetch handler outside a real Workers isolate -- + * see isCloudflareWorkerRuntime's doc comment -- so c.executionCtx can throw there). Mirrors + * src/mcp/server.ts's getExecutionContext exactly; kept local since it's an 8-line guard, not shared state. */ +function resolveExecutionCtx(c: Context): ExecutionContext { + try { + return c.executionCtx as unknown as ExecutionContext; + } catch { + return { waitUntil: () => {}, passThroughOnException: () => {}, exports: {}, props: {} } as unknown as ExecutionContext; + } +} + +/** Hono middleware: after any downstream middleware/handler runs, checks `c.error` and schedules a PostHog + * capture via waitUntil when it's set -- mirrors Hono's own documented pattern + * (https://hono.dev/docs/api/context#error), NOT a try/catch around next(). This app has no app.onError(), but + * Hono's Hono class always installs its OWN default errorHandler internally (hono-base.js) regardless -- every + * compose() dispatch level catches a thrown error at the exact index it occurred, hands it to that default + * handler (which logs it and finalizes a 500 Response), sets context.error, and returns normally. A plain + * try/catch around next() here would NEVER see the exception, because it's already been caught and converted + * to a response several dispatch levels below by the time next() resolves. A no-op wrapper (zero overhead + * beyond the check) when WORKER_POSTHOG_API_KEY is unset. + * + * Scope note: this captures errors that propagate through the Hono middleware/handler chain specifically, not + * every possible Workers-runtime failure mode (e.g. a crash reachable only via the raw fetch() export outside + * Hono's dispatch). Sentry's middleware (which additionally patches the Worker's fetch export via + * @sentry/cloudflare's withSentry) remains the more exhaustive safety net until the decommission gate -- + * consistent with #8288 being a parallel addition, not a byte-for-byte replacement. */ +export function createWorkerPostHogErrorMiddleware() { + return async (c: Context<{ Bindings: WorkerPostHogEnv }>, next: () => Promise): Promise => { + if (!isWorkerPostHogConfigured(c.env)) { + await next(); + return; + } + await next(); + if (c.error) { + resolveExecutionCtx(c).waitUntil(capturePostHogWorkerError(c.env, c.error, { path: c.req.path, method: c.req.method })); + } + }; +} diff --git a/src/env.d.ts b/src/env.d.ts index 9d401cc4a6..9895969df0 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -173,6 +173,20 @@ declare global { * AND this being unset) until both are true. Inject as a wrangler secret. */ WORKER_SENTRY_DSN?: string; WORKER_SENTRY_ENVIRONMENT?: string; + /** Cloudflare Worker error tracking (PostHog, #8288) -- the parallel-run PostHog counterpart to + * WORKER_SENTRY_DSN above, both active simultaneously when configured until the gated decommission (#8298). + * Deliberately NAMED DIFFERENTLY from the dual-purpose POSTHOG_API_KEY/POSTHOG_HOST (MCP telemetry + * #6228/#6235 + self-host error tracking #8287) for the SAME cross-wire reason WORKER_SENTRY_DSN is named + * differently from self-host's SENTRY_DSN: self-host's server.ts calls this same exported worker.fetch by + * synthesizing a Worker-shaped env from process.env, so reusing POSTHOG_API_KEY here would silently + * activate Worker-path exception capture inside a self-hoster's own Node process the moment they set + * POSTHOG_API_KEY for #8287 -- an unrelated, unintended cross-wire. Also lets an operator toggle + * Worker-level exception capture independently of MCP tool-call telemetry. Opt-in like every other + * Sentry/PostHog var in this codebase: a complete no-op until BOTH isCloudflareWorkerRuntime() (routes.ts) + * AND this is set. See src/api/worker-posthog.ts. Inject as a wrangler secret. */ + WORKER_POSTHOG_API_KEY?: string; + WORKER_POSTHOG_HOST?: string; + WORKER_POSTHOG_ENVIRONMENT?: string; /** The central Orb GitHub App's OWN credentials (separate from the loopover review App above). Inject as * wrangler secrets. Used to mint the Orb App JWT → list installations + mint short-lived installation tokens * (the token-broker). CLIENT_ID/SECRET drive the OAuth onboarding flow. */ diff --git a/test/unit/worker-posthog.test.ts b/test/unit/worker-posthog.test.ts new file mode 100644 index 0000000000..04c55d6a67 --- /dev/null +++ b/test/unit/worker-posthog.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// Mock posthog-node so the dynamic import inside buildClient() resolves to a spy-backed client, mirroring +// selfhost-posthog.test.ts's identical mocking pattern. +const mocks = vi.hoisted(() => { + const captureException = vi.fn(); + const flush = vi.fn().mockResolvedValue(undefined); + let lastApiKey: string | undefined; + let lastOptions: any; + const PostHog = vi.fn(function (this: any, apiKey: string, options: any) { + lastApiKey = apiKey; + lastOptions = options; + this.captureException = captureException; + this.flush = flush; + }); + return { captureException, flush, PostHog, getLastApiKey: () => lastApiKey, getLastOptions: () => lastOptions }; +}); +vi.mock("posthog-node", () => ({ PostHog: mocks.PostHog })); + +import { + capturePostHogWorkerError, + createWorkerPostHogErrorMiddleware, + isWorkerPostHogConfigured, + scrubWorkerPostHogEvent, + type WorkerPostHogEnv, +} from "../../src/api/worker-posthog"; +import { resetRedactionScrubForTest } from "../../src/selfhost/redaction-scrub"; + +beforeEach(() => { + vi.clearAllMocks(); + resetRedactionScrubForTest(); +}); + +describe("scrubWorkerPostHogEvent", () => { + it("passes through null unchanged", () => { + expect(scrubWorkerPostHogEvent(null)).toBeNull(); + }); + + it("redacts secret-shaped values in properties", () => { + const event = { event: "$exception", properties: { token: "gh" + "o_" + "a".repeat(20) } } as any; + const scrubbed = scrubWorkerPostHogEvent(event); + expect(scrubbed?.properties?.token).toBe("[redacted]"); + }); + + it("scrubs a secret-shaped event name", () => { + const secret = `Bearer ${"a".repeat(20)}`; + const event = { event: secret, properties: {} } as any; + const scrubbed = scrubWorkerPostHogEvent(event); + expect(scrubbed?.event).not.toBe(secret); + expect(scrubbed?.event).toContain("[redacted]"); + }); + + it("leaves an event with no properties untouched aside from its event name", () => { + const event = { event: "$exception" } as any; + expect(scrubWorkerPostHogEvent(event)).toEqual(event); + }); + + it("leaves a non-string event name untouched", () => { + const event = { event: undefined, properties: {} } as any; + expect(scrubWorkerPostHogEvent(event)).toEqual(event); + }); + + it("returns null when scrubbing itself throws", () => { + const event = { + event: "$exception", + get properties(): unknown { + throw new Error("boom"); + }, + } as any; + expect(scrubWorkerPostHogEvent(event)).toBeNull(); + }); +}); + +describe("isWorkerPostHogConfigured", () => { + it("is false when WORKER_POSTHOG_API_KEY is unset", () => { + expect(isWorkerPostHogConfigured({} as WorkerPostHogEnv)).toBe(false); + }); + it("is false when WORKER_POSTHOG_API_KEY is blank/whitespace", () => { + expect(isWorkerPostHogConfigured({ WORKER_POSTHOG_API_KEY: " " } as WorkerPostHogEnv)).toBe(false); + }); + it("is true when WORKER_POSTHOG_API_KEY is a non-blank string", () => { + expect(isWorkerPostHogConfigured({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv)).toBe(true); + }); +}); + +describe("capturePostHogWorkerError", () => { + it("is a no-op when unconfigured", async () => { + await capturePostHogWorkerError({} as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" }); + expect(mocks.PostHog).not.toHaveBeenCalled(); + }); + + it("captures with the configured key and default host when WORKER_POSTHOG_HOST is unset", async () => { + await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" }); + expect(mocks.getLastApiKey()).toBe("phc_test"); + expect(mocks.getLastOptions()).toMatchObject({ host: "https://us.i.posthog.com", flushAt: 1, flushInterval: 0 }); + }); + + it("uses WORKER_POSTHOG_HOST when set", async () => { + await capturePostHogWorkerError( + { WORKER_POSTHOG_API_KEY: "phc_test", WORKER_POSTHOG_HOST: "https://eu.i.posthog.com" } as WorkerPostHogEnv, + new Error("boom"), + { path: "/x", method: "GET" }, + ); + expect(mocks.getLastOptions()).toMatchObject({ host: "https://eu.i.posthog.com" }); + }); + + it("wraps a non-Error thrown value into a real Error before capturing", async () => { + await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, "not an error", { path: "/x", method: "GET" }); + const captured = mocks.captureException.mock.calls.at(-1)?.[0] as Error; + expect(captured).toBeInstanceOf(Error); + expect(captured.message).toBe("not an error"); + }); + + it("captures the request path/method and defaults environment to production", async () => { + await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/v1/orb/webhook", method: "POST" }); + const [error, distinctId, properties] = mocks.captureException.mock.calls.at(-1)!; + expect((error as Error).message).toBe("boom"); + expect(distinctId).toBe("loopover-worker"); + expect(properties).toMatchObject({ environment: "production", request_path: "/v1/orb/webhook", request_method: "POST" }); + }); + + it("uses WORKER_POSTHOG_ENVIRONMENT when set", async () => { + await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test", WORKER_POSTHOG_ENVIRONMENT: "staging" } as WorkerPostHogEnv, new Error("boom"), { + path: "/x", + method: "GET", + }); + const properties = mocks.captureException.mock.calls.at(-1)?.[2] as Record; + expect(properties.environment).toBe("staging"); + }); + + it("redacts a secret-shaped request path", async () => { + await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { + path: `/v1/orb/token/${"github" + "_pat_"}${"a".repeat(24)}`, + method: "GET", + }); + const properties = mocks.captureException.mock.calls.at(-1)?.[2] as Record; + expect(properties.request_path).not.toContain("github_pat_"); + expect(properties.request_path).toContain("[redacted]"); + }); + + it("awaits flush before resolving", async () => { + let flushed = false; + mocks.flush.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + flushed = true; + }); + await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" }); + expect(flushed).toBe(true); + }); + + it("never throws when the PostHog client construction fails", async () => { + mocks.PostHog.mockImplementationOnce(() => { + throw new Error("client construction failed"); + }); + await expect(capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" })).resolves.toBeUndefined(); + }); + + it("never throws when captureException itself throws", async () => { + mocks.captureException.mockImplementationOnce(() => { + throw new Error("capture failed"); + }); + await expect(capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" })).resolves.toBeUndefined(); + }); + + it("never throws when flush rejects", async () => { + mocks.flush.mockRejectedValueOnce(new Error("flush failed")); + await expect(capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" })).resolves.toBeUndefined(); + }); +}); + +/** Builds a minimal Hono app with the middleware mounted plus a route that always throws, and a fake + * executionCtx that captures whatever promise gets handed to waitUntil so the test can await it + * deterministically instead of racing the fire-and-forget capture. */ +function buildTestApp() { + const app = new Hono<{ Bindings: WorkerPostHogEnv }>(); + app.use(createWorkerPostHogErrorMiddleware()); + app.get("/ok", (c) => c.text("fine")); + app.get("/boom", () => { + throw new Error("handler exploded"); + }); + let waited: Promise = Promise.resolve(); + const executionCtx = { + waitUntil: (p: Promise) => { + waited = p; + }, + passThroughOnException: () => {}, + props: {}, + } as unknown as ExecutionContext; + return { app, executionCtx, getWaited: () => waited }; +} + +describe("createWorkerPostHogErrorMiddleware", () => { + it("is a transparent passthrough for a successful request when unconfigured", async () => { + const { app, executionCtx } = buildTestApp(); + const res = await app.fetch(new Request("https://loopover.test/ok"), {} as WorkerPostHogEnv, executionCtx); + expect(res.status).toBe(200); + expect(mocks.PostHog).not.toHaveBeenCalled(); + }); + + it("still surfaces a 500 (via Hono's own default error handler) without capturing when unconfigured", async () => { + const { app, executionCtx } = buildTestApp(); + const res = await app.fetch(new Request("https://loopover.test/boom"), {} as WorkerPostHogEnv, executionCtx); + expect(res.status).toBe(500); + expect(mocks.PostHog).not.toHaveBeenCalled(); + }); + + it("does not capture a successful request when configured", async () => { + const { app, executionCtx } = buildTestApp(); + const res = await app.fetch(new Request("https://loopover.test/ok"), { WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, executionCtx); + expect(res.status).toBe(200); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("schedules a capture via waitUntil and still surfaces the 500 when configured and a handler throws", async () => { + const { app, executionCtx, getWaited } = buildTestApp(); + const res = await app.fetch(new Request("https://loopover.test/boom"), { WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, executionCtx); + expect(res.status).toBe(500); + await getWaited(); + const [error, , properties] = mocks.captureException.mock.calls.at(-1)!; + expect((error as Error).message).toBe("handler exploded"); + expect((properties as Record).request_path).toBe("/boom"); + expect((properties as Record).request_method).toBe("GET"); + }); + + it("falls back to a no-op executionCtx when c.executionCtx throws (self-host calling the same Worker fetch handler outside a real isolate)", async () => { + const app = new Hono<{ Bindings: WorkerPostHogEnv }>(); + app.use(createWorkerPostHogErrorMiddleware()); + app.get("/boom", () => { + throw new Error("handler exploded"); + }); + // No third argument -- Hono's own executionCtx getter throws when none was supplied, exactly like a + // self-host Node process calling this exported fetch handler without a real Workers ExecutionContext. + const res = await app.fetch(new Request("https://loopover.test/boom"), { WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv); + expect(res.status).toBe(500); + }); +}); diff --git a/test/workers/worker-runtime.test.ts b/test/workers/worker-runtime.test.ts index ef871843ae..bef2cf62ca 100644 --- a/test/workers/worker-runtime.test.ts +++ b/test/workers/worker-runtime.test.ts @@ -28,4 +28,9 @@ describe("worker runtime", () => { const res = await worker.fetch(new Request("https://loopover.test/health"), { WORKER_SENTRY_DSN: undefined } as unknown as Env, createExecutionContext()); expect(res.status).toBe(200); }); + + it("still serves a normal response when WORKER_POSTHOG_API_KEY is unset -- the PostHog middleware (#8288) being registered must not itself break requests", async () => { + const res = await worker.fetch(new Request("https://loopover.test/health"), { WORKER_POSTHOG_API_KEY: undefined } as unknown as Env, createExecutionContext()); + expect(res.status).toBe(200); + }); });