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
117 changes: 117 additions & 0 deletions runner/apps/authoring/src/eventGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Two `beforeSend` suppression gates for populations the Sentry triage classified
// NOT-OURS (DEV-2858). Custom message filters are plan-locked on this project — one
// DSN across all three environments — so this has to be code.
//
// Split out of `sentry.ts` rather than written inline there, same reasoning as
// `reportingGate.ts:1-9` and `fetchFailure.ts:14-19`: that module imports
// `@sentry/react` and reads `import.meta.env`, neither of which node resolves, so
// nothing in it can be unit-tested. `pipeline/sentry-gating.test.mjs` imports THIS
// module directly under `--experimental-strip-types`, which cannot resolve sibling
// `./x.js` specifiers or npm packages. Do not let it grow imports — no `@sentry/react`
// types, no `./x.js` specifier, no `SessionStartDiagnostics` import. Parameters are
// structurally typed instead (same arrangement as `rehomeBudgetAlert` in
// `workers/api/src/sentry-gate.ts:82-84`).

/** The `exception` shape both gates below read from `Sentry.ErrorEvent` — declared
* locally, not imported, so this file stays resolvable by a bare `node --test`. */
interface ExceptionShape {
exception?: {
values?: {
value?: string;
type?: string;
mechanism?: { handled?: boolean };
}[];
};
}

/** The `tags` shape `isEdgelessForeignSessionStart` reads. Declared locally for the
* same import-free reason as `ExceptionShape` above. */
interface TaggedEvent {
tags?: Record<string, unknown>;
}

// ── Gate 1: DEMOS-5F — Microsoft Outlook/Office safelink scanner ────────────────
//
// The scanner injects script into the page that then throws its own unhandled
// rejection. One named regex, in the `fetchFailure.ts:29-32` house style, so an
// unrecognised wording surfaces as a new event instead of being folded in silently.
//
// Deliberately excluded from the pattern: the integers after `Id:` and
// `ParamCount:`, and `MethodName:` entirely. `MethodName` varies in the wild
// (`update`, `getInstance`, …) and the prose alone is already discriminating —
// dropping the integers beats `\d+`-ing them, since nothing downstream needs to
// know a number was there.
//
// DEGRADE DIRECTION, documented like `isPreviewPortUnreachable` /
// `isExpectedTeardownFailure` (`workers/api/src/session-lifecycle.ts:131-134`): this
// string comes from an injected third-party script, not from any package in this
// repo, so a Microsoft reword makes this predicate stop matching and the event
// reports again — noisy, never silent.
const INJECTED_SCANNER_MESSAGES = [
/Object Not Found Matching Id/i, // Microsoft Outlook/Office safelink scanner
];

/**
* True for an *unhandled* rejection/error whose text is the Office scanner's own
* injected failure.
*
* Both conjuncts required, mirroring `isUnhandledNoise` in `sentry.ts`:
* `mechanism.handled === false` is what distinguishes an unhandled global-handler
* event from anything reported on purpose (`captureException` sets
* `handled: true`), and the message/type must match the scanner's wording. Without
* the `handled` conjunct, an explicit `captureException` that happened to quote this
* text (there is a test for exactly this) would be silently dropped too.
*/
export function isOfficeScannerRejection(event: ExceptionShape): boolean {
const values = event.exception?.values ?? [];
return values.some(
(v) =>
v.mechanism?.handled === false &&
INJECTED_SCANNER_MESSAGES.some((re) => re.test(v.value ?? "") || re.test(v.type ?? "")),
);
}

// ── Gate 2: DEMOS-9 — the edgeless-foreign session-start facet ──────────────────
//
// Tags only, set at `App.tsx:292-305` — a file DEV-2854 owns; a rename there makes
// this gate stop matching, fail-open (noisy, never silent).
//
// `context` is a scoping conjunct so a future, unrelated reuse of
// `session_response_origin` elsewhere in the app is not silently silenced by this
// gate. `session_status` is deliberately NOT a conjunct — precedent at
// `pipeline/session-start-failure.test.mjs:464`: "the tier turns on where the
// response came from, not on the status."
//
// `cf_ray` is checked for a non-empty string, not with `in` / presence, so a
// hypothetical empty-string tag counts as absent rather than as present-and-truthy.
//
// THE `!cf_ray` CONJUNCT IS UNREACHABLE TODAY — KEEP IT ANYWAY.
// `sessionDiagnostics.ts:58` (`responseOrigin`) returns `"cloudflare"` whenever
// `ray` is truthy, so `"foreign"` already implies no ray: every event this gate can
// see today has no `cf_ray` tag at all, which makes `!cf_ray` look like dead code.
// A reviewer may read it that way and cite the `session-lifecycle.ts:145-154` rule
// — "no create-path event of it exists, so widening this predicate for it would be
// a guess dressed as a fact" — as licence to delete it. That rule forbids WIDENING
// a predicate to cover an unobserved input. `!cf_ray` is the opposite: a NARROWING
// conjunct, and a narrowing conjunct can only ever suppress LESS than the gate
// would without it, never more. Direction is the discriminator, not observability.
// It keeps this gate correct if the `responseOrigin` taxonomy in
// `sessionDiagnostics.ts` ever changes shape and starts emitting `"foreign"` for a
// ray-bearing response — which would be OUR side of the edge (a real invocation,
// not an intercepted one) and the actual capacity signal DEMOS-9 exists to protect,
// so it must not be silenced by this gate no matter how the origin taxonomy drifts.
//
// THIS DOES NOT ZERO THE 786. The ~88 events predating the DEV-2559 instrumentation
// deploy carry no diagnostics tags at all and keep reporting — correctly: an
// untagged event carries no evidence of being not-ours, so there is nothing here
// for the gate to match.
export function isEdgelessForeignSessionStart(event: TaggedEvent): boolean {
const tags = event.tags ?? {};
const cfRay = tags.cf_ray;
const hasRay = typeof cfRay === "string" && cfRay.length > 0;
return (
tags.context === "tier2-session-start" &&
tags.session_response_origin === "foreign" &&
!hasRay
);
}
19 changes: 19 additions & 0 deletions runner/apps/authoring/src/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@handsontable/demo-runtime/monitor";
import { ApiError } from "./apiError.js";
import { resolveReporting } from "./reportingGate.js";
import { isEdgelessForeignSessionStart, isOfficeScannerRejection } from "./eventGate.js";

const DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined;

Expand Down Expand Up @@ -79,6 +80,12 @@ const DEMO_SURFACE = "demo-runtime";
* processes every event including explicit `captureException` calls, so
* `/Failed to fetch/` there would silently discard the offline broker and
* `/api/versions` failures that `reportError` exists to surface.
*
* The two other NOT-OURS populations this project has classified — the Office
* scanner rejection (DEMOS-5F) and the edgeless-foreign session-start facet
* (DEMOS-9) — are NOT regexes here. They live in `eventGate.ts`, gated in
* `beforeSend` below, and are pinned by `pipeline/sentry-gating.test.mjs`. Adding
* another regex to this array for either would lose that test coverage.
*/
const UNHANDLED_NOISE = [
/^ResizeObserver loop/i,
Expand Down Expand Up @@ -151,6 +158,18 @@ if (reportingEnabled) {
maxBreadcrumbs: 200,
beforeSend(event) {
if (isUnhandledNoise(event)) return null;
// DEMOS-5F, Office/Outlook safelink scanner (DEV-2858). Sits ahead of the
// DEMO_SURFACE branch, unlike isForeignUnhandled below: it requires
// `mechanism.handled === false`, and every relay arrives via
// `captureException`, which sets `handled: true` — so it cannot fire on a
// relayed event and needs no re-homing protection.
if (isOfficeScannerRejection(event)) return null;
// DEMOS-9, edgeless-foreign session-start facet (DEV-2858). Also sits ahead
// of the DEMO_SURFACE branch: it requires the `tier2-session-start` /
// `session_response_origin` tags that only `App.tsx`'s own
// `Sentry.captureException` call sets — `reportDemoEvent` (:254-260) never
// sets them, so this gate cannot fire on a relayed event either.
if (isEdgelessForeignSessionStart(event)) return null;
// A client carries one `environment` from init, so a relayed demo event is
// re-homed per event here. See `reportDemoEvent`.
if (event.tags?.surface === DEMO_SURFACE) {
Expand Down
5 changes: 4 additions & 1 deletion runner/docs/run-and-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,10 @@ subdomain label are redacted.
`sandpack.ts`, `onStderr`/`relayStderr` in `container.ts`, `injectMonitor` in the
Worker, and `monitorDemos` / `reportDemoEvent` in `sentry.ts` plus the relay listener
in `App.tsx`. The `beforeSend` narrowing in `sentry.ts` is **not** part of this
feature and must stay — it is a fix in its own right. Neither is the
feature and must stay — it is a fix in its own right. Neither are the two
DEMOS-5F / DEMOS-9 suppression gates it also calls: they live in `eventGate.ts`,
are pinned by `pipeline/sentry-gating.test.mjs`, and are not part of the
monitor-removal path either. Neither is the
`tier1-compiler-asset` branch of `tier1Report` (DEV-2569): it sits ahead of the
`monitorDemos` gate precisely so that removing this feature does not take our own
compiler asset failing to load down with it.
Expand Down
199 changes: 199 additions & 0 deletions runner/pipeline/sentry-gating.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
apiSentryEnvironment,
rehomeBudgetAlert,
} from "../workers/api/src/sentry-gate.ts";
import {
isEdgelessForeignSessionStart,
isOfficeScannerRejection,
} from "../apps/authoring/src/eventGate.ts";

// DEV-2540. Three classes of traffic reached the production Sentry project that had
// no business being there — local dev sessions, a Playwright run pointed at
Expand Down Expand Up @@ -143,3 +147,198 @@ test("ordinary events pass through untouched", () => {
assert.equal(out.environment, undefined);
}
});

// ── DEV-2858. beforeSend suppression gates for two NOT-OURS populations ─────────
//
// `isOfficeScannerRejection` (DEMOS-5F) and `isEdgelessForeignSessionStart`
// (DEMOS-9) live in `eventGate.ts`, import-free for the same reason as
// `sentry-gate.ts` above. P = dropped (positive match), N = reported (must survive).

// ── DEMOS-5F: Office/Outlook safelink scanner ────────────────────────────────────

test("P1: the scanner's injected rejection is dropped", () => {
// A failure to drop this means the gate's regex or its handled-conjunct broke.
const event = {
exception: {
values: [
{
type: "UnhandledRejection",
value:
"Non-Error promise rejection captured with value: Object Not Found Matching Id:12, MethodName:update, ParamCount:4",
mechanism: { handled: false },
},
],
},
};
assert.equal(isOfficeScannerRejection(event), true);
});

test("P2: a different Id/MethodName/ParamCount is still dropped", () => {
// Fails if anyone hardcodes Id:12 / MethodName:update instead of matching the
// discriminating prose around the varying fields.
const event = {
exception: {
values: [
{
type: "UnhandledRejection",
value:
"Non-Error promise rejection captured with value: Object Not Found Matching Id:9, MethodName:getInstance, ParamCount:2",
mechanism: { handled: false },
},
],
},
};
assert.equal(isOfficeScannerRejection(event), true);
});

test("N1: the same text reported on purpose (handled: true) survives", () => {
// An explicit captureException quoting this text must not be silently dropped —
// guards the mechanism.handled === false conjunct, not just the regex.
const event = {
exception: {
values: [
{
type: "Error",
value: "Object Not Found Matching Id:12, MethodName:update, ParamCount:4",
mechanism: { handled: true },
},
],
},
};
assert.equal(isOfficeScannerRejection(event), false);
});

test("N2: an unrelated 'not found' unhandled error survives", () => {
// Guards against a loose /not found/i standing in for the real, discriminating
// phrase.
const event = {
exception: {
values: [
{
type: "Error",
value: "Configuration object not found for column 3",
mechanism: { handled: false },
},
],
},
};
assert.equal(isOfficeScannerRejection(event), false);
});

test("N3: an event with no exception values does not throw and is not dropped", () => {
// Guards an unguarded event.exception.values deref.
assert.equal(isOfficeScannerRejection({ exception: { values: [] } }), false);
assert.equal(isOfficeScannerRejection({}), false);
});

// ── DEMOS-9: edgeless-foreign session-start facet ────────────────────────────────
//
// Tag shapes mirror the ones App.tsx:292-305 actually sets.

test("P1: foreign origin with no cf_ray is dropped", () => {
const event = {
tags: {
context: "tier2-session-start",
session_status: "504",
session_response_origin: "foreign",
session_response_type: "basic",
session_elapsed_bucket: "<1s",
framework: "angular",
},
};
assert.equal(isEdgelessForeignSessionStart(event), true);
});

test("P2: a different session_status is still dropped", () => {
// Fails if someone adds a status conjunct — the tier turns on where the response
// came from, not on the status (pipeline/session-start-failure.test.mjs:464).
const event = {
tags: {
context: "tier2-session-start",
session_status: "503",
session_response_origin: "foreign",
session_response_type: "basic",
session_elapsed_bucket: "<1s",
framework: "angular",
},
};
assert.equal(isEdgelessForeignSessionStart(event), true);
});

test("N1 (highest value in this change): a foreign-shaped event WITH a cf_ray is reported", () => {
// Unreachable today — sessionDiagnostics.ts's responseOrigin only returns
// "foreign" when there is no ray. This is a spec guard on an input the taxonomy
// does not currently produce, kept because the !cf_ray conjunct is narrowing
// (can only suppress less), not widening (see eventGate.ts's comment). Fails the
// moment the gate is collapsed to an origin-only check.
const event = {
tags: {
context: "tier2-session-start",
session_status: "504",
session_response_origin: "foreign",
session_response_type: "basic",
session_elapsed_bucket: "<1s",
framework: "angular",
cf_ray: "9a1b2c3d4e5f6789-WAW",
},
};
assert.equal(isEdgelessForeignSessionStart(event), false);
});

test("N2: the real capacity signal — cloudflare origin with a ray — is reported", () => {
// Fails if the gate is collapsed to a ray-only check instead of requiring the
// foreign origin too.
const event = {
tags: {
context: "tier2-session-start",
session_status: "504",
session_response_origin: "cloudflare",
cf_ray: "9a1b2c3d4e5f6789-WAW",
},
};
assert.equal(isEdgelessForeignSessionStart(event), false);
});

test("N3: an 'unreadable' origin (cross-origin dev) is reported", () => {
// headersReadable is called load-bearing at session-start-failure.test.mjs:432 —
// absence of a ray proves nothing when headers cannot be read at all.
const event = {
tags: {
context: "tier2-session-start",
session_response_origin: "unreadable",
},
};
assert.equal(isEdgelessForeignSessionStart(event), false);
});

test("N4: a 'headerless' origin is reported", () => {
// One taxonomy value is suppressed by this gate, not three.
const event = {
tags: {
context: "tier2-session-start",
session_response_origin: "headerless",
},
};
assert.equal(isEdgelessForeignSessionStart(event), false);
});

test("N5: an untagged pre-instrumentation event is reported", () => {
// The ~88 events predating DEV-2559's diagnostics tags. Guards a
// !== "cloudflare" inversion — an absent session_response_origin must not be
// treated as evidence of "foreign".
const event = { tags: { context: "tier2-session-start", session_status: "504" } };
assert.equal(isEdgelessForeignSessionStart(event), false);
});

test("N6: a foreign-shaped event under a different context is reported", () => {
// Guards the context scoping conjunct — a future reuse of
// session_response_origin outside tier2-session-start must not be silently
// silenced.
const event = { tags: { context: "tier2-container-boot", session_response_origin: "foreign" } };
assert.equal(isEdgelessForeignSessionStart(event), false);
});

test("N7: an event with no tags at all does not throw and is not dropped", () => {
assert.equal(isEdgelessForeignSessionStart({}), false);
assert.equal(isEdgelessForeignSessionStart({ tags: {} }), false);
});
Loading