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
Centralise frontend error reporting (and suppress unactionable Sentry errors) #3850
Changes from all commits
0dbfe0b
e2649b2
9747a6f
7700aa6
0686896
affb7fd
37c86af
40f9569
8973e8c
8c8719a
9379db2
c6102cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,147 @@ | ||||||||||||||||||||||||||||||
import axios from "axios" | ||||||||||||||||||||||||||||||
import { Plugin, Context } from "@nuxt/types" | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
import { ERR_UNKNOWN, ErrorCode, errorCodes } from "~/constants/errors" | ||||||||||||||||||||||||||||||
import type { FetchingError, RequestKind } from "~/types/fetch-state" | ||||||||||||||||||||||||||||||
import type { SupportedSearchType } from "~/constants/media" | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
const isValidErrorCode = ( | ||||||||||||||||||||||||||||||
code: string | undefined | null | ||||||||||||||||||||||||||||||
): code is ErrorCode => { | ||||||||||||||||||||||||||||||
if (!code) { | ||||||||||||||||||||||||||||||
return false | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
return (errorCodes as readonly string[]).includes(code) | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
function isDetailedResponseData(data: unknown): data is { detail: string } { | ||||||||||||||||||||||||||||||
return !!data && typeof data === "object" && "detail" in data | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||
* Normalize any error occurring during a network call. | ||||||||||||||||||||||||||||||
* | ||||||||||||||||||||||||||||||
* @param error - Any error arising during a network call | ||||||||||||||||||||||||||||||
* @param searchType - The type of search selected when the error occurred | ||||||||||||||||||||||||||||||
* @param requestKind - The kind of request the error occurred for | ||||||||||||||||||||||||||||||
* @param details - Any additional details to attach to the error | ||||||||||||||||||||||||||||||
* @returns Normalized error object | ||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||
export function normalizeFetchingError( | ||||||||||||||||||||||||||||||
error: unknown, | ||||||||||||||||||||||||||||||
searchType: SupportedSearchType, | ||||||||||||||||||||||||||||||
requestKind: RequestKind, | ||||||||||||||||||||||||||||||
details?: Record<string, string> | ||||||||||||||||||||||||||||||
): FetchingError { | ||||||||||||||||||||||||||||||
const fetchingError: FetchingError = { | ||||||||||||||||||||||||||||||
requestKind, | ||||||||||||||||||||||||||||||
details, | ||||||||||||||||||||||||||||||
searchType, | ||||||||||||||||||||||||||||||
code: ERR_UNKNOWN, | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
if (!axios.isAxiosError(error)) { | ||||||||||||||||||||||||||||||
fetchingError.message = (error as Error).message | ||||||||||||||||||||||||||||||
return fetchingError | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
// Otherwise, it's an AxiosError | ||||||||||||||||||||||||||||||
if (isValidErrorCode(error.code)) { | ||||||||||||||||||||||||||||||
fetchingError.code = error.code | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
if (error.response?.status) { | ||||||||||||||||||||||||||||||
fetchingError.statusCode = error.response.status | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
const responseData = error?.response?.data | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
// Use the message returned by the API. | ||||||||||||||||||||||||||||||
if (isDetailedResponseData(responseData)) { | ||||||||||||||||||||||||||||||
fetchingError.message = responseData.detail as string | ||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||
fetchingError.message = error.message | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
return fetchingError | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||
* Record network errors using the appropriate tool, as needed, | ||||||||||||||||||||||||||||||
* based on response code, status, and request kind. | ||||||||||||||||||||||||||||||
* @param error - The error to record | ||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||
function recordError( | ||||||||||||||||||||||||||||||
context: Context, | ||||||||||||||||||||||||||||||
originalError: unknown, | ||||||||||||||||||||||||||||||
fetchingError: FetchingError | ||||||||||||||||||||||||||||||
) { | ||||||||||||||||||||||||||||||
if (fetchingError.statusCode === 429) { | ||||||||||||||||||||||||||||||
// These are more readily monitored via the Cloudflare dashboard. | ||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
if ( | ||||||||||||||||||||||||||||||
fetchingError.requestKind === "single-result" && | ||||||||||||||||||||||||||||||
fetchingError.statusCode === 404 | ||||||||||||||||||||||||||||||
) { | ||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||
* Do not record 404s for single result requests because: | ||||||||||||||||||||||||||||||
* 1. Plausible will already record them as resulting in a 404 page view | ||||||||||||||||||||||||||||||
* 2. The Openverse API 404s on malformed identifiers, so there is no way | ||||||||||||||||||||||||||||||
* to distinguish between truly not found works and bad requests from | ||||||||||||||||||||||||||||||
* the client side. | ||||||||||||||||||||||||||||||
* 3. There isn't much we can do other than monitor for an anomalously high | ||||||||||||||||||||||||||||||
* number of 404 responses from the frontend server that could indicate a frontend | ||||||||||||||||||||||||||||||
* implementation or configuration error suddenly causing malformed | ||||||||||||||||||||||||||||||
* identifiers to be used. Neither Sentry nor Plausible are the right tool | ||||||||||||||||||||||||||||||
* for that task. If the 404s are caused by an API issue, we'd see that in | ||||||||||||||||||||||||||||||
* API response code monitoring, where we can more easily trace the cause | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
if (process.client && fetchingError.code === "ERR_NETWORK") { | ||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||
* Record network errors in Plausible so that we can evaluate potential | ||||||||||||||||||||||||||||||
* regional or device configuration issues, for which Sentry is not | ||||||||||||||||||||||||||||||
* as good a tool. Additionally, the number of these events are trivial | ||||||||||||||||||||||||||||||
* for Plausible, but do actually affect our Sentry quota enough that it | ||||||||||||||||||||||||||||||
* is worth diverting them. | ||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||
context.$sendCustomEvent("NETWORK_ERROR", { | ||||||||||||||||||||||||||||||
requestKind: fetchingError.requestKind, | ||||||||||||||||||||||||||||||
searchType: fetchingError.searchType, | ||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||
context.$sentry.captureException(originalError, { | ||||||||||||||||||||||||||||||
extra: { fetchingError }, | ||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
Comment on lines
+116
to
+121
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ``
Suggested change
Style nit: perhaps this would make adding future additional cases less error-prone, or avoid confusion if folks think there's some special relationship between the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I went back and forth many times on this, and ultimately found both just as confusing, and went with a coin toss. I'll switch this, as any actual preference is better than my 50/50 non-choice! |
||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
function createProcessFetchingError( | ||||||||||||||||||||||||||||||
context: Context | ||||||||||||||||||||||||||||||
): typeof normalizeFetchingError { | ||||||||||||||||||||||||||||||
function processFetchingError( | ||||||||||||||||||||||||||||||
...[originalError, ...args]: Parameters<typeof normalizeFetchingError> | ||||||||||||||||||||||||||||||
) { | ||||||||||||||||||||||||||||||
const fetchingError = normalizeFetchingError(originalError, ...args) | ||||||||||||||||||||||||||||||
recordError(context, originalError, fetchingError) | ||||||||||||||||||||||||||||||
return fetchingError | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
return processFetchingError | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
declare module "@nuxt/types" { | ||||||||||||||||||||||||||||||
interface Context { | ||||||||||||||||||||||||||||||
$processFetchingError: ReturnType<typeof createProcessFetchingError> | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
const plugin: Plugin = async (context, inject) => { | ||||||||||||||||||||||||||||||
inject("processFetchingError", createProcessFetchingError(context)) | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
export default plugin |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -2,7 +2,7 @@ import { defineStore } from "pinia" | |||||||||
|
||||||||||
import { warn } from "~/utils/console" | ||||||||||
import { hash, rand as prng } from "~/utils/prng" | ||||||||||
import { isRetriable, parseFetchingError } from "~/utils/errors" | ||||||||||
import { isRetriable } from "~/utils/errors" | ||||||||||
import type { | ||||||||||
AudioDetail, | ||||||||||
DetailFromMediaType, | ||||||||||
|
@@ -499,15 +499,17 @@ export const useMediaStore = defineStore("media", { | |||||||||
}) | ||||||||||
return mediaCount | ||||||||||
} catch (error: unknown) { | ||||||||||
const errorData = parseFetchingError(error, mediaType, "search", { | ||||||||||
searchTerm: queryParams.q ?? "", | ||||||||||
}) | ||||||||||
const errorData = this.$nuxt.$processFetchingError( | ||||||||||
error, | ||||||||||
mediaType, | ||||||||||
"search", | ||||||||||
{ | ||||||||||
searchTerm: queryParams.q ?? "", | ||||||||||
} | ||||||||||
Comment on lines
+506
to
+508
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Nit: these seem more readable when on a single line |
||||||||||
) | ||||||||||
|
||||||||||
this._updateFetchState(mediaType, "end", errorData) | ||||||||||
|
||||||||||
this.$nuxt.$sentry.captureException(error, { | ||||||||||
extra: { errorData }, | ||||||||||
}) | ||||||||||
return null | ||||||||||
} | ||||||||||
}, | ||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -1,6 +1,5 @@ | ||||||||||
import { defineStore } from "pinia" | ||||||||||
|
||||||||||
import { parseFetchingError } from "~/utils/errors" | ||||||||||
import { initServices } from "~/stores/media/services" | ||||||||||
|
||||||||||
import type { FetchingError, FetchState } from "~/types/fetch-state" | ||||||||||
|
@@ -59,9 +58,14 @@ export const useRelatedMediaStore = defineStore("related-media", { | |||||||||
|
||||||||||
return this.media.length | ||||||||||
} catch (error) { | ||||||||||
const errorData = parseFetchingError(error, mediaType, "related", { | ||||||||||
id, | ||||||||||
}) | ||||||||||
const errorData = this.$nuxt.$processFetchingError( | ||||||||||
error, | ||||||||||
mediaType, | ||||||||||
"related", | ||||||||||
{ | ||||||||||
id, | ||||||||||
} | ||||||||||
Comment on lines
+65
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Nit |
||||||||||
) | ||||||||||
|
||||||||||
this._endFetching(errorData) | ||||||||||
this.$nuxt.$sentry.captureException(error, { extra: { errorData } }) | ||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This docstring needs to be updated.