diff --git a/packages/fxa-react/lib/AppLocalizationProvider.test.tsx b/packages/fxa-react/lib/AppLocalizationProvider.test.tsx index d3a570b47e7..c3b43b2fde1 100644 --- a/packages/fxa-react/lib/AppLocalizationProvider.test.tsx +++ b/packages/fxa-react/lib/AppLocalizationProvider.test.tsx @@ -14,6 +14,11 @@ import { Localized } from '@fluent/react'; import fetchMock from 'fetch-mock'; import AppLocalizationProvider from './AppLocalizationProvider'; +// `it` negotiates to exactly ['it', 'en'], which keeps the set of requested +// bundle paths small enough to assert on precisely. +const HASHED_BASE_DIR = '/hashed'; +const HASHED_LOCALES = ['it']; + describe('', () => { const locales = ['en-GB', 'en-US', 'es-ES']; const bundles = ['greetings', 'farewells']; @@ -26,21 +31,46 @@ describe('', () => { } beforeAll(() => { + // Keys must match the path `fetchMessages` builds, which has no leading + // slash. `farewells` is absent for every locale but en-US, so those + // lookups miss and Fluent falls back. fetchMock.get( '/static-asset-manifest.json', - ` - { - "/locales/en-US/greetings.ftl": "/locales/en-US/greetings.ftl", - "/locales/en-US/farewells.ftl": "/locales/en-US/farewells.ftl", - "/locales/es-ES/greetings.ftl": "/locales/es-ES/greetings.ftl", - "/locales/en-GB/greetings.ftl": "/locales/en-GB/greetings.ftl", - } - ` + JSON.stringify({ + 'locales/en-US/greetings.ftl': 'locales/en-US/greetings.ftl', + 'locales/en-US/farewells.ftl': 'locales/en-US/farewells.ftl', + 'locales/es-ES/greetings.ftl': 'locales/es-ES/greetings.ftl', + 'locales/en-GB/greetings.ftl': 'locales/en-GB/greetings.ftl', + }) ); fetchMock.get('/locales/en-US/greetings.ftl', 'hello = Hello\n'); fetchMock.get('/locales/en-US/farewells.ftl', 'goodbye = Goodbye\n'); fetchMock.get('/locales/es-ES/greetings.ftl', 'hello = Hola\n'); fetchMock.get('/locales/en-GB/greetings.ftl', 'hello = Hello { $amount }'); + + // A well-formed manifest, served under its own baseDir so it does not + // collide with the invalid-manifest fixture above. `farewells` is + // deliberately absent from it, and `notfound` maps to a path that 404s. + fetchMock.get( + `${HASHED_BASE_DIR}/static-asset-manifest.json`, + JSON.stringify({ + 'locales/it/greetings.ftl': 'locales/it/greetings.1a2b3c.ftl', + 'locales/en/greetings.ftl': 'locales/en/greetings.4d5e6f.ftl', + 'locales/it/notfound.ftl': 'locales/it/notfound.7a8b9c.ftl', + 'locales/en/notfound.ftl': 'locales/en/notfound.7a8b9c.ftl', + }) + ); + fetchMock.get( + `${HASHED_BASE_DIR}/locales/it/greetings.1a2b3c.ftl`, + 'hello = Ciao\n' + ); + fetchMock.get( + `${HASHED_BASE_DIR}/locales/en/greetings.4d5e6f.ftl`, + 'hello = Hello\n' + ); + fetchMock.get(`${HASHED_BASE_DIR}/locales/it/notfound.7a8b9c.ftl`, 404); + fetchMock.get(`${HASHED_BASE_DIR}/locales/en/notfound.7a8b9c.ftl`, 404); + fetchMock.get('*', { throws: new Error() }); }); @@ -169,4 +199,86 @@ describe('', () => { expect(getByTestId('result')).toHaveTextContent('Hello ⁨$US123.00⁩'); }); + + describe('reportBundleError', () => { + function renderWithManifest( + bundlesToLoad: Array, + reportBundleError: jest.Mock, + baseDir = HASHED_BASE_DIR + ) { + return render( + +
+ +
untranslated
+
+
+
+ ); + } + + it('resolves the hashed path from the manifest and reports nothing', async () => { + const reportBundleError = jest.fn(); + const { getByTestId } = renderWithManifest( + ['greetings'], + reportBundleError + ); + await waitUntilTranslated(); + + expect(getByTestId('result')).toHaveTextContent('Ciao'); + expect(reportBundleError).not.toHaveBeenCalled(); + }); + + it('reports a bundle with no entry in the manifest, once per locale', async () => { + const reportBundleError = jest.fn(); + const { getByTestId } = renderWithManifest( + ['farewells'], + reportBundleError + ); + await waitUntilTranslated(); + + expect( + reportBundleError.mock.calls.map(([error]) => error.message) + ).toEqual([ + 'No static asset mapping for l10n bundle: locales/it/farewells.ftl', + 'No static asset mapping for l10n bundle: locales/en/farewells.ftl', + ]); + expect(getByTestId('result')).toHaveTextContent('untranslated'); + }); + + it('reports a bundle whose hashed path does not resolve, once per locale', async () => { + const reportBundleError = jest.fn(); + renderWithManifest(['notfound'], reportBundleError); + await waitUntilTranslated(); + + expect( + reportBundleError.mock.calls.map(([error]) => error.message) + ).toEqual([ + `Fetching l10n bundle returned 404: ${HASHED_BASE_DIR}/locales/it/notfound.7a8b9c.ftl`, + `Fetching l10n bundle returned 404: ${HASHED_BASE_DIR}/locales/en/notfound.7a8b9c.ftl`, + ]); + }); + + it('reports an unreachable manifest', async () => { + const reportBundleError = jest.fn(); + renderWithManifest(['greetings'], reportBundleError, '/no-manifest'); + await waitUntilTranslated(); + + expect(reportBundleError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'Fetching l10n static asset manifest failed: /no-manifest/static-asset-manifest.json' + ), + }) + ); + // Without mappings the unhashed paths are requested and fail too, so a + // manifest outage costs one report plus one per negotiated locale. + expect(reportBundleError).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/packages/fxa-react/lib/AppLocalizationProvider.tsx b/packages/fxa-react/lib/AppLocalizationProvider.tsx index 903603eb36f..68ed02620fd 100644 --- a/packages/fxa-react/lib/AppLocalizationProvider.tsx +++ b/packages/fxa-react/lib/AppLocalizationProvider.tsx @@ -7,45 +7,68 @@ import { LocalizationProvider, ReactLocalization } from '@fluent/react'; import React, { Component } from 'react'; import { EN_GB_LOCALES, parseAcceptLanguage } from '@fxa/shared/l10n'; +type ReportError = (error: Error) => void; + +function describeCause(err: unknown) { + return err instanceof Error ? err.message : String(err); +} + /** * Gets l10n messages from server * @param baseDir The root location where locales folders are held * @param locale The target language * @param bundle The target bundle (ie main) * @param mappings A set of mappings for static resources. + * @param reportBundleError Receives whole-bundle load failures. * @returns */ async function fetchMessages( baseDir: string, locale: string, bundle: string, - mappings?: Record + mappings?: Record, + reportBundleError?: ReportError ) { + // Build the path to l10n file + const path = `locales/${locale}/${bundle}.ftl`; + + // If mappings were provided see if there is one for the path. This + // will be a location where the file path contains a hash in the file + // name + const mappedPath = mappings ? mappings[path] : path; + + // If we don't have mapped path, there are no l10n resources for this language. + if (!mappedPath) { + reportBundleError?.( + new Error(`No static asset mapping for l10n bundle: ${path}`) + ); + return ''; + } + + // Fetch the file and return the messages + const resolvedPath = `${baseDir}/${mappedPath}`; try { - // Build the path to l10n file - let path = `locales/${locale}/${bundle}.ftl`; - - // If mappings were proivided see if there is one for the path. This - // will be a location where the file path contains a hash in the file - // name - if (mappings) { - path = mappings[path]; - } + const response = await fetch(resolvedPath); - // If we don't have mapped path, there are no l10n resources for this language. - if (!path) { + // A non-OK body is not FTL, and handing it to Fluent yields an empty bundle. + if (!response.ok) { + reportBundleError?.( + new Error( + `Fetching l10n bundle returned ${response.status}: ${resolvedPath}` + ) + ); return ''; } - // Fetch the file and return the messages - const resolvedPath = `${baseDir}/${path}`; - const response = await fetch(resolvedPath); - const messages = await response.text(); - - return messages; + return await response.text(); } catch (e) { // We couldn't fetch any strings; just return nothing and fluent will fall // back to the default locale if needed. + reportBundleError?.( + new Error( + `Fetching l10n bundle failed: ${resolvedPath} (${describeCause(e)})` + ) + ); return ''; } } @@ -54,21 +77,36 @@ function fetchAllMessages( baseDir: string, locale: string, bundles: Array, - mappings?: Record + mappings?: Record, + reportBundleError?: ReportError ) { return Promise.all( - bundles.map((bndl) => fetchMessages(baseDir, locale, bndl, mappings)) + bundles.map((bndl) => + fetchMessages(baseDir, locale, bndl, mappings, reportBundleError) + ) ); } -async function fetchL10nHashedMappings(mappingUrl: string) { +async function fetchL10nHashedMappings( + mappingUrl: string, + reportBundleError?: ReportError +) { try { // These mappigns are currently generated with grunt. See grunt task hash-static // in fxa-settings for an example of how the mappings are generated. const mappingsResponse = await fetch(mappingUrl); - const json = await mappingsResponse.json(); - return json; + if (!mappingsResponse.ok) { + throw new Error(`Received status ${mappingsResponse.status}`); + } + return await mappingsResponse.json(); } catch (err) { + reportBundleError?.( + new Error( + `Fetching l10n static asset manifest failed: ${mappingUrl} (${describeCause( + err + )})` + ) + ); return undefined; } } @@ -76,17 +114,25 @@ async function fetchL10nHashedMappings(mappingUrl: string) { async function createFluentBundleGenerator( baseDir: string, currentLocales: Array, - bundles: Array + bundles: Array, + reportBundleError?: ReportError ) { const mappings = await fetchL10nHashedMappings( - `${baseDir}/static-asset-manifest.json` + `${baseDir}/static-asset-manifest.json`, + reportBundleError ); const fetched = await Promise.all( currentLocales .filter((l) => !EN_GB_LOCALES.includes(l)) .map(async (locale) => { return { - [locale]: await fetchAllMessages(baseDir, locale, bundles, mappings), + [locale]: await fetchAllMessages( + baseDir, + locale, + bundles, + mappings, + reportBundleError + ), }; }) ); @@ -127,7 +173,11 @@ type Props = { children: any; // pass messages directly in, used in testing messages?: { [key: string]: string[] }; - reportError?: (error: Error) => void; + // Per-string Fluent errors, e.g. an id missing from the bundle. Defaults to + // @fluent/react's console reporter. + reportError?: ReportError; + // Failures to load a bundle at all, where no string in it can resolve. + reportBundleError?: ReportError; }; export default class AppLocalizationProvider extends Component { @@ -137,6 +187,7 @@ export default class AppLocalizationProvider extends Component { bundles: ['main'], children: React.createElement('div'), reportError: undefined, + reportBundleError: undefined, }; constructor(props: Props) { @@ -170,7 +221,8 @@ export default class AppLocalizationProvider extends Component { const bundleGenerator = await createFluentBundleGenerator( baseDir, currentLocales, - bundles + bundles, + this.props.reportBundleError ); this.setState({ l10n: new ReactLocalization( diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 3ff87160866..7a380b01f81 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -41,6 +41,7 @@ import { import { AccountStateProvider } from '../../models/contexts/AccountStateContext'; import sentryMetrics from 'fxa-shared/sentry/browser'; +import { flushL10nErrorReports } from '../../lib/l10n-error-reporter'; // Components import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; import { ScrollToTop } from '../Settings/ScrollToTop'; @@ -377,6 +378,9 @@ export const App = ({ flowQueryParams }: { flowQueryParams: QueryParams }) => { useEffect(() => { if (metricsEnabled || isSignedIn === false) { sentryMetrics.enable(); + // l10n bundles are fetched before this component can mount, so any + // failure there is buffered until Sentry will accept it. + flushL10nErrorReports(); } else { sentryMetrics.disable(); } diff --git a/packages/fxa-settings/src/contexts/DynamicLocalizationContext.tsx b/packages/fxa-settings/src/contexts/DynamicLocalizationContext.tsx index 0cbb25822c4..d286843997b 100644 --- a/packages/fxa-settings/src/contexts/DynamicLocalizationContext.tsx +++ b/packages/fxa-settings/src/contexts/DynamicLocalizationContext.tsx @@ -2,7 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from 'react'; import AppLocalizationProvider from 'fxa-react/lib/AppLocalizationProvider'; import { isRTLLocale, @@ -11,7 +17,7 @@ import { getCurrentLocale, validateLocale, detectBrowserDefaultLocale, - DEFAULT_LOCALE + DEFAULT_LOCALE, } from '../lib/locales'; interface DynamicLocalizationContextType { @@ -21,15 +27,20 @@ interface DynamicLocalizationContextType { isLoading: boolean; } -const DynamicLocalizationContext = createContext(null); +const DynamicLocalizationContext = + createContext(null); export const DynamicLocalizationProvider: React.FC<{ children: React.ReactNode; baseDir: string; bundles?: string[]; -}> = ({ children, baseDir, bundles = ['main'] }) => { + reportBundleError?: (error: Error) => void; +}> = ({ children, baseDir, bundles = ['main'], reportBundleError }) => { const [currentLocale, setCurrentLocale] = useState(() => getCurrentLocale()); - const [userLocales, setUserLocales] = useState(() => [getCurrentLocale(), DEFAULT_LOCALE]); + const [userLocales, setUserLocales] = useState(() => [ + getCurrentLocale(), + DEFAULT_LOCALE, + ]); const [isLoading, setIsLoading] = useState(false); const [key, setKey] = useState(0); // Force re-render of AppLocalizationProvider @@ -40,7 +51,7 @@ export const DynamicLocalizationProvider: React.FC<{ if (newLocale !== currentLocale) { setCurrentLocale(newLocale); setUserLocales([newLocale, DEFAULT_LOCALE]); - setKey(prev => prev + 1); + setKey((prev) => prev + 1); } }; @@ -50,40 +61,42 @@ export const DynamicLocalizationProvider: React.FC<{ }; }, [currentLocale]); - const switchLanguage = useCallback(async (locale: string) => { - // Validate against supported languages - if (!validateLocale(locale)) { - console.warn(`Locale ${locale} is not supported`); - return; - } - - if (locale === currentLocale) { - return; // No change needed - } + const switchLanguage = useCallback( + async (locale: string) => { + // Validate against supported languages + if (!validateLocale(locale)) { + console.warn(`Locale ${locale} is not supported`); + return; + } - setIsLoading(true); + if (locale === currentLocale) { + return; // No change needed + } - try { - // 1. Save preference to localStorage - saveLocalePreference(locale); + setIsLoading(true); - // 2. Update document attributes - document.documentElement.lang = locale; - document.documentElement.dir = isRTLLocale(locale) ? 'rtl' : 'ltr'; + try { + // 1. Save preference to localStorage + saveLocalePreference(locale); - // 3. Update state - setCurrentLocale(locale); - setUserLocales([locale, DEFAULT_LOCALE]); + // 2. Update document attributes + document.documentElement.lang = locale; + document.documentElement.dir = isRTLLocale(locale) ? 'rtl' : 'ltr'; - // 4. Force AppLocalizationProvider to re-mount and reload bundles - setKey(prev => prev + 1); + // 3. Update state + setCurrentLocale(locale); + setUserLocales([locale, DEFAULT_LOCALE]); - } catch (error) { - // Language switch failed, we can't really do anything about it so ignore it - } finally { - setIsLoading(false); - } - }, [currentLocale]); + // 4. Force AppLocalizationProvider to re-mount and reload bundles + setKey((prev) => prev + 1); + } catch (error) { + // Language switch failed, we can't really do anything about it so ignore it + } finally { + setIsLoading(false); + } + }, + [currentLocale] + ); const clearLanguagePreference = useCallback(async () => { setIsLoading(true); @@ -97,15 +110,16 @@ export const DynamicLocalizationProvider: React.FC<{ // 3. Update document attributes document.documentElement.lang = browserDefaultLocale; - document.documentElement.dir = isRTLLocale(browserDefaultLocale) ? 'rtl' : 'ltr'; + document.documentElement.dir = isRTLLocale(browserDefaultLocale) + ? 'rtl' + : 'ltr'; // 4. Update state setCurrentLocale(browserDefaultLocale); setUserLocales([browserDefaultLocale, DEFAULT_LOCALE]); // 5. Force AppLocalizationProvider to re-mount and reload bundles - setKey(prev => prev + 1); - + setKey((prev) => prev + 1); } catch (error) { // Clear failed, ignore it } finally { @@ -114,17 +128,20 @@ export const DynamicLocalizationProvider: React.FC<{ }, []); return ( - + {children} @@ -140,7 +157,7 @@ export const useDynamicLocalization = () => { currentLocale: DEFAULT_LOCALE, switchLanguage: async () => {}, clearLanguagePreference: async () => {}, - isLoading: false + isLoading: false, }; } return context; diff --git a/packages/fxa-settings/src/index.tsx b/packages/fxa-settings/src/index.tsx index 5bc3acd5770..bb379c200cf 100644 --- a/packages/fxa-settings/src/index.tsx +++ b/packages/fxa-settings/src/index.tsx @@ -19,6 +19,7 @@ import Storage from './lib/storage'; import CookiesDisabled from './pages/CookiesDisabled'; import { BrowserRouter } from 'react-router'; import { DynamicLocalizationProvider } from './contexts/DynamicLocalizationContext'; +import { reportL10nError } from './lib/l10n-error-reporter'; export interface FlowQueryParams { broker?: string; @@ -83,7 +84,10 @@ try { render( - + diff --git a/packages/fxa-settings/src/lib/l10n-error-reporter.test.ts b/packages/fxa-settings/src/lib/l10n-error-reporter.test.ts new file mode 100644 index 00000000000..42b32b60dd1 --- /dev/null +++ b/packages/fxa-settings/src/lib/l10n-error-reporter.test.ts @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as Sentry from '@sentry/browser'; +import { + MAX_L10N_ERROR_REPORTS, + flushL10nErrorReports, + reportL10nError, + resetL10nErrorReporter, +} from './l10n-error-reporter'; + +jest.mock('@sentry/browser', () => ({ + captureException: jest.fn(), +})); + +const mockCaptureException = Sentry.captureException as jest.MockedFunction< + typeof Sentry.captureException +>; + +describe('reportL10nError', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetL10nErrorReporter(); + document.documentElement.lang = 'fr'; + }); + + describe('before metrics are enabled', () => { + it('does not send to Sentry, which would discard the event', () => { + reportL10nError(new Error('No static asset mapping for l10n bundle')); + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('sends the buffered reports once flushed', () => { + const first = new Error( + 'No static asset mapping for locales/fr/main.ftl' + ); + const second = new Error('Fetching l10n static asset manifest failed'); + + reportL10nError(first); + reportL10nError(second); + flushL10nErrorReports(); + + expect(mockCaptureException).toHaveBeenCalledTimes(2); + expect(mockCaptureException).toHaveBeenCalledWith(first, { + tags: { area: 'l10n' }, + extra: { locale: 'fr' }, + }); + }); + + it('reports the locale from when the failure happened, not the flush', () => { + const error = new Error( + 'No static asset mapping for locales/fr/main.ftl' + ); + + reportL10nError(error); + document.documentElement.lang = 'de'; + flushL10nErrorReports(); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { area: 'l10n' }, + extra: { locale: 'fr' }, + }); + }); + }); + + describe('after metrics are enabled', () => { + beforeEach(() => { + flushL10nErrorReports(); + }); + + it('reports the error to Sentry tagged as l10n with the current locale', () => { + const error = new Error('No static asset mapping for l10n bundle'); + + reportL10nError(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { area: 'l10n' }, + extra: { locale: 'fr' }, + }); + }); + + it('reports a repeated message only once', () => { + const message = + 'No static asset mapping for l10n bundle: locales/fr/main.ftl'; + reportL10nError(new Error(message)); + reportL10nError(new Error(message)); + + expect(mockCaptureException).toHaveBeenCalledTimes(1); + }); + + it(`stops reporting after ${MAX_L10N_ERROR_REPORTS} distinct messages`, () => { + for (let i = 0; i < MAX_L10N_ERROR_REPORTS + 5; i++) { + reportL10nError(new Error(`No static asset mapping for bundle ${i}`)); + } + + expect(mockCaptureException).toHaveBeenCalledTimes( + MAX_L10N_ERROR_REPORTS + ); + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/l10n-error-reporter.ts b/packages/fxa-settings/src/lib/l10n-error-reporter.ts new file mode 100644 index 00000000000..dc3766dbdf0 --- /dev/null +++ b/packages/fxa-settings/src/lib/l10n-error-reporter.ts @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as Sentry from '@sentry/browser'; + +/** + * Sentry starts disabled and is only enabled once we know the user has not + * opted out of metrics, which happens inside `App`. `App` cannot mount until + * AppLocalizationProvider has resolved its bundles, so every bundle failure is + * reported before Sentry will accept anything. Reports are therefore buffered + * and flushed by `flushL10nErrorReports` once metrics are enabled — if the user + * has opted out, the buffer is simply never flushed. + */ +export const MAX_L10N_ERROR_REPORTS = 10; + +type L10nErrorReport = { + error: Error; + locale: string; +}; + +const reportedMessages = new Set(); +let pendingReports: Array = []; +let metricsEnabled = false; + +function captureReport({ error, locale }: L10nErrorReport) { + Sentry.captureException(error, { + tags: { area: 'l10n' }, + extra: { locale }, + }); +} + +/** + * Reports a failure to load a localization bundle. Individual missing string ids + * are deliberately left to Fluent's console reporter — the signal worth alerting + * on is "no strings resolved at all". + * + * One report is possible per locale per bundle, and switching languages remounts + * the provider and refetches, so reports are deduped by message and capped for + * the life of the page. A widespread manifest failure still means a handful of + * events per session; tune the volume Sentry-side rather than here, so the first + * report of a new failure is never the one that gets dropped. + */ +export function reportL10nError(error: Error) { + if ( + reportedMessages.has(error.message) || + reportedMessages.size >= MAX_L10N_ERROR_REPORTS + ) { + return; + } + reportedMessages.add(error.message); + + // The locale is read now rather than at flush time, since a language switch + // can happen in between. + const report = { error, locale: document.documentElement.lang }; + + if (metricsEnabled) { + captureReport(report); + } else { + pendingReports.push(report); + } +} + +/** + * Called once metrics are known to be enabled. Sends anything reported during + * app startup and lets later reports through immediately. + */ +export function flushL10nErrorReports() { + metricsEnabled = true; + const queued = pendingReports; + pendingReports = []; + queued.forEach(captureReport); +} + +// Exported for tests; the cap, dedup set and buffer are page-lifetime state. +export function resetL10nErrorReporter() { + reportedMessages.clear(); + pendingReports = []; + metricsEnabled = false; +}