-
-
Notifications
You must be signed in to change notification settings - Fork 19
Instrument urls built from a JavaScript undefined (BL-16666) #8175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JohnThomson
wants to merge
8
commits into
master
Choose a base branch
from
BL-16666-undefined-url-instrumentation
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+635
−4
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
cc26cb9
Instrument urls built from a JavaScript undefined (BL-16666)
JohnThomson 0585c64
Point the code comments at the new card, BL-16666
JohnThomson 3450686
Merge remote-tracking branch 'origin/master' into BL-16666-undefined-…
JohnThomson 284dde3
Don't interrupt users with our own url bug, and fix a stale test (BL-…
JohnThomson c2438fb
Actually include both kinds of detail in the report (BL-16666)
JohnThomson 79858a5
Make the two "is this our url bug?" decisions share one derivation (B…
JohnThomson b8c9cb7
Source-map the detector's stacks, or they name a bundle not our code …
JohnThomson 4607a0b
Key the once-only rule on the call site, not the message (BL-16666)
JohnThomson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
|
JohnThomson marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| // Catches the moment a piece of our front-end code puts a JavaScript value it doesn't actually | ||
| // have into a url. | ||
| // | ||
| // `element.src = someUndefinedVariable` doesn't fail: the DOM turns the value into the *text* | ||
| // "undefined" and asks the server for a file by that name. The server can only report that some | ||
| // file called "undefined" is missing, which tells us nothing about who asked for it - so these | ||
| // have sat in Sentry for years without ever being traceable to a line of code (BL-16666, split from BL-16577). We | ||
| // already fixed one instance the hard way (BL-16447, where the Adjust Timings dialog rendered | ||
| // before its audio url was ready); this exists so the next one names itself. | ||
| // | ||
| // The interception below is deliberately passive: it reports and then lets the assignment | ||
| // proceed exactly as before. What we need from it is the stack, and we get that either way, so | ||
| // there is no reason to change what the app does. | ||
| // | ||
| // Coverage, so nobody reads a silence here as proof of innocence: this patches the prototypes of | ||
| // *the window it is installed in*. Bloom's edit screen puts the book page and the toolbox in | ||
| // iframes, and elements created inside a frame are instances of that frame's own constructors. We | ||
| // are installed in every frame that runs one of our bundles, since each bundle's root module | ||
| // imports errorHandler - but parent-frame code that reaches into a child frame's document | ||
| // (`pageIframe.contentDocument.createElement("img").src = x`, a pattern bookEdit does use) is | ||
| // creating elements from the child's constructors while running in the parent, and is not seen. | ||
|
|
||
| /// The exact strings JavaScript produces when these values are turned into text. We match them | ||
| /// case-sensitively and only as a whole url or a whole path segment, so a real file that happens | ||
| /// to be called "Undefined.png", or a folder called "undefinedThings", is left alone. | ||
| const javascriptValueStrings = ["undefined", "null", "NaN"]; | ||
|
|
||
| /** | ||
| * True if this will become a url that is, or contains a path segment that is, the text form of a | ||
| * JavaScript value. Nothing legitimately loads such a url, so a true here is always one of our bugs. | ||
| * | ||
| * Takes the value rather than a string on purpose. At an assignment like `img.src = x` the value | ||
| * arrives here as the real `undefined`; it is the DOM that turns it into the text "undefined" a | ||
| * moment later. By the time anyone could see a string it is already too late to know who did it. | ||
| */ | ||
| export function isJavascriptValueUrl(url: unknown): boolean { | ||
| // These are exactly the values that stringify to the text we are looking for. | ||
| if (url === undefined || url === null) return true; | ||
| if (typeof url === "number") return Number.isNaN(url); | ||
| // Anything else that isn't a string (a URL object, say) stringifies to something real. | ||
| if (typeof url !== "string" || url.length === 0) return false; | ||
|
|
||
| // Ignore any query string or fragment; only the path can name a file. | ||
| const path = url.split(/[?#]/)[0]; | ||
| return path | ||
| .split(/[/\\]/) | ||
| .some((segment) => javascriptValueStrings.includes(segment)); | ||
| } | ||
|
|
||
| /** | ||
| * What we send to the server. Kept separate from the interception so it can be tested, and so the | ||
| * message reads the same wherever it was caught. | ||
| */ | ||
| export function describeBadUrl(url: unknown, whereItWasSet: string): string { | ||
| return ( | ||
| `A url was built from a JavaScript value that wasn't ready: ${whereItWasSet} was set to "${String(url)}". ` + | ||
| `Bloom will now ask the server for a file by that name and fail. See BL-16666.` | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Takes the Error rather than its stack text, so the caller can source-map it. A raw stack points | ||
| * into a bundle, which is close to useless for a feature whose whole job is to name a line of our | ||
| * source. errorHandler does that mapping, since it already owns it for window.onerror. | ||
| */ | ||
| type Reporter = (message: string, error: Error) => void; | ||
|
|
||
| let installed = false; | ||
|
|
||
| // Each report is an http post to the server, and the bug that triggers it is typically a render | ||
| // that repeats - the Adjust Timings dialog in BL-16447 would have fired on every open. We only | ||
| // need to learn about each site once, so report a given problem once and stop altogether after a | ||
| // handful. Losing the repeat count costs us nothing: the point is to identify the code, and the | ||
| // server-side count in Sentry already tells us how often it happens. | ||
| // | ||
| // "A given problem" has to mean the call site, not the message. Every component that sets an | ||
| // image src to undefined produces the *same* message, so keying on that would report whichever | ||
| // one happened to run first and hide every other one for the rest of the session - which is | ||
| // exactly the kind of blind spot this whole feature exists to remove. | ||
| const maxReports = 10; | ||
| const alreadyReported = new Set<string>(); | ||
|
|
||
| /** | ||
| * What makes two reports "the same problem": the message plus the whole stack. We use the entire | ||
| * stack rather than trying to pick out "the caller" — any rule for finding that frame has to guess | ||
| * which frames are ours, and a wrong guess lands on a frame two different call sites share, which | ||
| * silently reinstates the blind spot. The stack is stable for repeats from one site, and if two | ||
| * routes reach the same bug, hearing about both is useful rather than noise. | ||
| */ | ||
| function reportKey(message: string, error: Error): string { | ||
| return message + "|" + (error.stack ?? ""); | ||
| } | ||
|
|
||
| /** Exported only so tests can start from a clean slate. */ | ||
| export function resetReportingForTests(): void { | ||
| alreadyReported.clear(); | ||
| } | ||
|
|
||
| /** | ||
| * Installs the interception. Safe to call more than once; only the first call does anything, so a | ||
| * bundle that pulls in the bootstrap twice doesn't end up double-reporting. | ||
| * | ||
| * @param report how to get the problem back to us - errorHandler's reportError in real use. | ||
| */ | ||
| export function installUndefinedUrlDetector(report: Reporter): void { | ||
| if (installed || typeof window === "undefined") return; | ||
| installed = true; | ||
|
|
||
| const check = (url: unknown, whereItWasSet: string) => { | ||
| if (!isJavascriptValueUrl(url)) return; | ||
| const message = describeBadUrl(url, whereItWasSet); | ||
| // The stack of *this* call is the whole point: it names the line that built the url. | ||
| const error = new Error(message); | ||
| const key = reportKey(message, error); | ||
| if (alreadyReported.has(key) || alreadyReported.size >= maxReports) | ||
| return; | ||
| alreadyReported.add(key); | ||
| report(message, error); | ||
| }; | ||
|
|
||
| guardSrcProperty(window.HTMLImageElement, "an image's src", check); | ||
| guardSrcProperty(window.HTMLMediaElement, "an audio/video src", check); | ||
| guardSrcProperty(window.HTMLIFrameElement, "an iframe's src", check); | ||
| guardSrcProperty(window.HTMLScriptElement, "a script's src", check); | ||
| guardSetAttribute(check); | ||
| guardFetch(check); | ||
| guardXmlHttpRequest(check); | ||
| } | ||
|
|
||
| /** | ||
| * Wraps the `src` property of one element type. We call the original setter afterwards, so the | ||
| * element behaves exactly as it did before; we are only listening. | ||
| */ | ||
| function guardSrcProperty( | ||
| elementType: { prototype: object } | undefined, | ||
| description: string, | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| // Not every environment has every element type (jsdom, for one), and a browser could in | ||
| // principle define src without a setter. Nothing to wrap in that case. | ||
| if (!elementType) return; | ||
| const original = Object.getOwnPropertyDescriptor( | ||
| elementType.prototype, | ||
| "src", | ||
| ); | ||
| if (!original || !original.set) return; | ||
|
|
||
| Object.defineProperty(elementType.prototype, "src", { | ||
| ...original, | ||
| set(value: unknown) { | ||
| check(value, description); | ||
| original.set!.call(this, value); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * The other way an element's src gets set. Wrapping `setAttribute` on `Element` covers every | ||
| * element type at once, including any we didn't wrap a `src` property for. | ||
| */ | ||
| function guardSetAttribute( | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| const originalSetAttribute = Element.prototype.setAttribute; | ||
| Element.prototype.setAttribute = function ( | ||
| name: string, | ||
| value: string, | ||
| ): void { | ||
| // Only src: an <a href> of "undefined" is a dead link rather than a failed request, and | ||
| // is not what we are hunting. | ||
| if (name === "src") check(value, `setAttribute("src")`); | ||
| originalSetAttribute.call(this, name, value); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * The BL-16447 case came in through a library (WaveSurfer) fetching the url rather than putting it | ||
| * on an element, so element interception alone would have missed it. | ||
| */ | ||
| function guardFetch( | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| if (typeof window.fetch !== "function") return; | ||
| const originalFetch = window.fetch; | ||
| window.fetch = function (input: RequestInfo | URL, init?: RequestInit) { | ||
| check(urlOfFetchInput(input), "a fetch()"); | ||
| // Call it on `window`, not on `this`. A bare `fetch(url)` inside a module gives us | ||
| // `this === undefined`, and the browser rejects fetch invoked on anything that isn't the | ||
| // window ("Illegal invocation") - which would break every fetch in Bloom. | ||
| return originalFetch.call(window, input, init); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * fetch() accepts a string, a URL, or a Request; pull the url out of whichever we were given so | ||
| * the check sees the same thing the network will. | ||
| */ | ||
| function urlOfFetchInput(input: RequestInfo | URL): string | undefined { | ||
| if (typeof input === "string") return input; | ||
| if (input instanceof URL) return input.href; | ||
| // A Request object | ||
| if (input && typeof (input as Request).url === "string") | ||
| return (input as Request).url; | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Older code (and some libraries) still request through XMLHttpRequest rather than fetch, and | ||
| * `open` is where the url is named, so that is what we wrap. | ||
| */ | ||
| function guardXmlHttpRequest( | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| if (typeof XMLHttpRequest !== "function") return; | ||
| const originalOpen = XMLHttpRequest.prototype.open; | ||
| // Deliberately untyped rest args: open() has two overloads and we only care about the url. | ||
| XMLHttpRequest.prototype.open = function ( | ||
| method: string, | ||
| url: string | URL, | ||
| ...rest: unknown[] | ||
| ) { | ||
| check(typeof url === "string" ? url : url?.href, "an XMLHttpRequest"); | ||
| return (originalOpen as (...args: unknown[]) => void).call( | ||
| this, | ||
| method, | ||
| url, | ||
| ...rest, | ||
| ); | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.