Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 0 additions & 67 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,6 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-trace-node": "^2.9.0",
"@sentry/cloudflare": "^10.63.0",
"@sentry/hono": "^10.63.0",
"agents": "^0.17.3",
"aws4fetch": "^1.0.20",
"drizzle-orm": "^0.45.2",
Expand Down
20 changes: 6 additions & 14 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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";
Expand Down Expand Up @@ -1127,8 +1126,8 @@ function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state:
* for this project's compatibility_date, sets `navigator.userAgent` to this exact literal -- Cloudflare's own
* documented idiom for this check). Self-host's server.ts calls the SAME exported `worker.fetch` this app
* produces (it synthesizes a Worker-shaped `env` by spreading `process.env` specifically so it can reuse this
* handler byte-for-byte) -- gating the Cloudflare-only Sentry middleware on this, rather than on env var
* presence alone, is what keeps it from ever activating inside a self-hoster's own Node process. */
* handler byte-for-byte) -- gating the Cloudflare-only PostHog error middleware on this, rather than on env
* var presence alone, is what keeps it from ever activating inside a self-hoster's own Node process. */
export function isCloudflareWorkerRuntime(): boolean {
return typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers";
}
Expand All @@ -1145,22 +1144,15 @@ function internalOpsAgentConfig(env: Env): OpsAgentConfig {

export function createApp() {
const app = new Hono<AppBindings>();
// Registered FIRST/outermost (Sentry's own guidance) so it wraps every other middleware and route below,
// including a thrown exception from the CORS/rate-limit middleware right after this. No-ops completely
// outside a real Workers isolate (see isCloudflareWorkerRuntime) and when WORKER_SENTRY_DSN is unset -- this
// is the Worker-side counterpart to self-host's own initSentry()/installStructuredLogForwarding(), which
// this Worker has never had any equivalent of despite being the actual central Orb broker server.
// Registered FIRST/outermost so it wraps every other middleware and route below, including a thrown
// exception from the CORS/rate-limit middleware right after this. REPLACES the old Sentry middleware
// entirely (2026-07-25 epic #8286 correction: full replacement, not a parallel-run). No-ops completely
// outside a real Workers isolate (see isCloudflareWorkerRuntime) and when WORKER_POSTHOG_API_KEY is unset.
/* v8 ignore start -- the TRUE branch only genuinely exercises inside a real Workers isolate (this vitest
* run is Node); covered instead by test/workers/worker-runtime.test.ts, which runs under
* @cloudflare/vitest-pool-workers and is NOT part of this coverage-instrumented run. isCloudflareWorkerRuntime
* 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 */
Expand Down
26 changes: 12 additions & 14 deletions src/api/worker-posthog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 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.
// Hosted ORB Worker PostHog error tracking (#8288, epic #8286). REPLACES the old @sentry/hono/cloudflare
// middleware entirely (2026-07-25 epic correction: full replacement, not a parallel-run) -- same treatment
// as #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
Expand Down Expand Up @@ -33,14 +33,12 @@ 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
* POSTHOG_HOST (MCP telemetry #6228/#6235 + self-host error tracking #8287): 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: 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: a
* complete no-op until BOTH isCloudflareWorkerRuntime() AND this is set. Inject as a wrangler secret. */
export type WorkerPostHogEnv = Pick<Env, "WORKER_POSTHOG_API_KEY" | "WORKER_POSTHOG_HOST" | "WORKER_POSTHOG_ENVIRONMENT">;

Expand Down Expand Up @@ -142,9 +140,9 @@ function resolveExecutionCtx(c: Context): ExecutionContext<unknown> {
*
* 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. */
* Hono's dispatch). Sentry's old middleware additionally patched the Worker's fetch export via
* @sentry/cloudflare's withSentry for that broader case -- this codebase has no PostHog equivalent, since every
* real route in this app is dispatched through Hono anyway. */
export function createWorkerPostHogErrorMiddleware() {
return async (c: Context<{ Bindings: WorkerPostHogEnv }>, next: () => Promise<void>): Promise<void> => {
if (!isWorkerPostHogConfigured(c.env)) {
Expand Down
29 changes: 9 additions & 20 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,26 +164,15 @@ declare global {
/** Webhook secret for the central LoopOver Orb GitHub App (#1255) — distinct from the review app's
* GITHUB_WEBHOOK_SECRET. Verifies inbound POST /v1/orb/webhook deliveries. Inject as a wrangler secret. */
ORB_GITHUB_WEBHOOK_SECRET?: string;
/** Cloudflare Worker error tracking (@sentry/hono/cloudflare). Deliberately NAMED DIFFERENTLY from
* self-host's own SENTRY_DSN (src/selfhost/sentry.ts): server.ts synthesizes a Worker-shaped `env` by
* spreading `process.env` and calls the SAME shared `worker.fetch` this Worker exports, so reusing the
* self-host var name would silently activate the Cloudflare-only SDK inside a self-hoster's own Node
* process the moment they set their OWN SENTRY_DSN — an unrelated, unintended cross-wire. Opt-in like
* every other Sentry var in this codebase: a complete no-op (routes.ts's isCloudflareWorkerRuntime guard
* 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. */
/** Cloudflare Worker error tracking (PostHog, #8288). REPLACES the old @sentry/hono/cloudflare middleware
* entirely (2026-07-25 epic #8286 correction). Deliberately NAMED DIFFERENTLY from the dual-purpose
* POSTHOG_API_KEY/POSTHOG_HOST (MCP telemetry #6228/#6235 + self-host error tracking #8287): 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: 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;
Expand Down
10 changes: 5 additions & 5 deletions test/unit/cloudflare-worker-runtime-detection.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import { isCloudflareWorkerRuntime } from "../../src/api/routes";

describe("isCloudflareWorkerRuntime (gates the Worker-only Sentry middleware in createApp)", () => {
describe("isCloudflareWorkerRuntime (gates the Worker-only PostHog error middleware in createApp)", () => {
it("is false under plain Node -- the exact condition self-host's server.ts runs createApp()'s shared handler under", () => {
// No mocking here on purpose: this asserts the REAL behavior of the real test runtime (Node), which is
// also self-host's real runtime -- Node's own native `navigator.userAgent` is "Node.js/<version>", never
// the Workers-specific literal. If this ever started returning true under Node, the Cloudflare-only Sentry
// SDK would activate inside every self-hoster's own process the moment they set WORKER_SENTRY_DSN by
// accident (e.g. copy-pasting the wrong var name) -- a real (Workers-only), test/workers/worker-runtime.test.ts
// proves the true side in an actual workerd isolate.
// the Workers-specific literal. If this ever started returning true under Node, the Cloudflare-only PostHog
// Worker capture would activate inside every self-hoster's own process the moment they set
// WORKER_POSTHOG_API_KEY by accident (e.g. copy-pasting the wrong var name) -- a real (Workers-only),
// test/workers/worker-runtime.test.ts proves the true side in an actual workerd isolate.
expect(isCloudflareWorkerRuntime()).toBe(false);
expect(navigator.userAgent).not.toBe("Cloudflare-Workers");
});
Expand Down
7 changes: 1 addition & 6 deletions test/workers/worker-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,11 @@ describe("worker runtime", () => {
expect(mcp.status).toBe(401);
});

it("REGRESSION: isCloudflareWorkerRuntime() is true in a real Workers isolate (the gate that lets the Sentry middleware register at all)", () => {
it("REGRESSION: isCloudflareWorkerRuntime() is true in a real Workers isolate (the gate that lets the PostHog error middleware register at all)", () => {
expect(isCloudflareWorkerRuntime()).toBe(true);
expect(navigator.userAgent).toBe("Cloudflare-Workers");
});

it("still serves a normal response when WORKER_SENTRY_DSN is unset -- the Sentry middleware being registered must not itself break requests", async () => {
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);
Expand Down
Loading