From 7717a2a1afed6e379c0fcabb52c1c826f6b47ba1 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Thu, 4 Jul 2024 17:13:47 +0200 Subject: [PATCH] feat: add log service --- .../src/controllers/AccountController.ts | 13 ++++--- .../common/src/controllers/AppController.ts | 3 ++ .../src/controllers/EpgController.test.ts | 12 ++----- .../common/src/controllers/EpgController.ts | 8 ++--- packages/common/src/logger.ts | 26 ++++++++++++++ packages/common/src/modules/container.ts | 9 +++-- packages/common/src/services/ApiService.ts | 3 +- .../common/src/services/FavoriteService.ts | 6 ++-- .../common/src/services/SettingsService.ts | 12 +++---- .../src/services/WatchHistoryService.ts | 4 +-- .../common/src/services/epg/JWEpgService.ts | 6 ++-- .../src/services/epg/ViewNexaEpgService.ts | 6 ++-- .../integrations/cleeng/CleengService.ts | 15 ++++---- .../integrations/jwp/JWPProfileService.ts | 5 +-- .../services/logging/ConsoleTransporter.ts | 34 +++++++++++++++++++ .../common/src/services/logging/LogLevel.ts | 14 ++++++++ .../src/services/logging/LogTransporter.ts | 5 +++ packages/common/src/utils/common.ts | 12 +------ packages/common/src/utils/urlFormatting.ts | 18 ++++++++-- packages/common/test/mockService.ts | 31 +++++++++++------ packages/common/types/static.d.ts | 1 + packages/common/vitest.config.ts | 1 + packages/common/vitest.setup.ts | 15 ++++++++ packages/hooks-react/src/useBootstrapApp.ts | 6 +++- packages/hooks-react/src/useProfiles.ts | 6 ++-- packages/hooks-react/vitest.config.ts | 1 + packages/hooks-react/vitest.setup.ts | 15 ++++++++ .../src/components/Account/Account.tsx | 5 +-- .../ui-react/src/components/Player/Player.tsx | 5 +-- .../AccountModal/forms/ResetPassword.tsx | 6 ++-- packages/ui-react/vitest.config.ts | 1 + packages/ui-react/vitest.setup.ts | 17 ++++++++++ platforms/web/src/App.tsx | 3 ++ platforms/web/src/modules/register.ts | 18 +++++++++- .../web/src/services/LocalStorageService.ts | 7 ++-- platforms/web/test/vitest.setup.ts | 15 ++++++++ platforms/web/vite.config.ts | 5 +-- 37 files changed, 282 insertions(+), 87 deletions(-) create mode 100644 packages/common/src/logger.ts create mode 100644 packages/common/src/services/logging/ConsoleTransporter.ts create mode 100644 packages/common/src/services/logging/LogLevel.ts create mode 100644 packages/common/src/services/logging/LogTransporter.ts diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts index 5c876c941..b56f802b0 100644 --- a/packages/common/src/controllers/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -2,7 +2,6 @@ import i18next from 'i18next'; import { inject, injectable } from 'inversify'; import { DEFAULT_FEATURES } from '../constants'; -import { logDev } from '../utils/common'; import type { IntegrationType } from '../../types/config'; import CheckoutService from '../services/integrations/CheckoutService'; import AccountService, { type AccountServiceFeatures } from '../services/integrations/AccountService'; @@ -24,6 +23,7 @@ import { useAccountStore } from '../stores/AccountStore'; import { useConfigStore } from '../stores/ConfigStore'; import { useProfileStore } from '../stores/ProfileStore'; import { FormValidationError } from '../errors/FormValidationError'; +import { logError } from '../logger'; import WatchHistoryController from './WatchHistoryController'; import ProfileController from './ProfileController'; @@ -68,7 +68,7 @@ export default class AccountController { await this.getAccount(); } } catch (error: unknown) { - logDev('Failed to get user', error); + logError('AccountController', 'Failed to get user', { error }); // clear the session when the token was invalid // don't clear the session when the error is unknown (network hiccup or something similar) @@ -386,7 +386,12 @@ export default class AccountController { return !!responseData?.accessGranted; }; - reloadSubscriptions = async ({ delay, retry }: { delay?: number; retry?: number } = { delay: 0, retry: 0 }): Promise => { + reloadSubscriptions = async ( + { delay, retry }: { delay?: number; retry?: number } = { + delay: 0, + retry: 0, + }, + ): Promise => { useAccountStore.setState({ loading: true }); const { getAccountInfo } = useAccountStore.getState(); @@ -436,7 +441,7 @@ export default class AccountController { pendingOffer = offerResponse.responseData; } } catch (error: unknown) { - logDev('Failed to fetch the pending offer', error); + logError('AccountController', 'Failed to fetch the pending offer', { error }); } // let the app know to refresh the entitlements diff --git a/packages/common/src/controllers/AppController.ts b/packages/common/src/controllers/AppController.ts index c4db83831..e750f5c67 100644 --- a/packages/common/src/controllers/AppController.ts +++ b/packages/common/src/controllers/AppController.ts @@ -10,6 +10,7 @@ import type { Config } from '../../types/config'; import type { CalculateIntegrationType } from '../../types/calculate-integration-type'; import { DETERMINE_INTEGRATION_TYPE } from '../modules/types'; import { useConfigStore } from '../stores/ConfigStore'; +import { logDebug } from '../logger'; import WatchHistoryController from './WatchHistoryController'; import FavoritesController from './FavoritesController'; @@ -63,6 +64,8 @@ export default class AppController { }; initializeApp = async (url: string, refreshEntitlements?: () => Promise) => { + logDebug('AppController', 'Initializing app', { url }); + const settings = await this.settingsService.initialize(); const configSource = await this.settingsService.getConfigSource(settings, url); const config = await this.loadAndValidateConfig(configSource); diff --git a/packages/common/src/controllers/EpgController.test.ts b/packages/common/src/controllers/EpgController.test.ts index b4f44865f..69d71d6ca 100644 --- a/packages/common/src/controllers/EpgController.test.ts +++ b/packages/common/src/controllers/EpgController.test.ts @@ -5,6 +5,7 @@ import livePlaylistFixture from '@jwp/ott-testing/fixtures/livePlaylist.json'; import EpgService from '../services/EpgService'; import type { Playlist } from '../../types/playlist'; +import { mockService } from '../../test/mockService'; import EpgController from './EpgController'; @@ -13,15 +14,6 @@ const livePlaylist = livePlaylistFixture as Playlist; const transformProgram = vi.fn(); const fetchSchedule = vi.fn(); -vi.mock('@jwp/ott-common/src/modules/container', () => ({ - getNamedModule: (type: typeof EpgService) => { - switch (type) { - case EpgService: - return { transformProgram, fetchSchedule }; - } - }, -})); - const epgController = new EpgController(); const mockProgram1 = { @@ -46,13 +38,13 @@ const mockProgram2 = { describe('epgService', () => { beforeEach(() => { + mockService(EpgService, { transformProgram, fetchSchedule }); vi.useFakeTimers(); }); afterEach(() => { // must be called before `vi.useRealTimers()` unregister(); - vi.restoreAllMocks(); vi.useRealTimers(); }); diff --git a/packages/common/src/controllers/EpgController.ts b/packages/common/src/controllers/EpgController.ts index edbb44587..6656c924f 100644 --- a/packages/common/src/controllers/EpgController.ts +++ b/packages/common/src/controllers/EpgController.ts @@ -4,16 +4,16 @@ import { injectable } from 'inversify'; import EpgService from '../services/EpgService'; import { EPG_TYPE } from '../constants'; import { getNamedModule } from '../modules/container'; -import { logDev } from '../utils/common'; import type { PlaylistItem } from '../../types/playlist'; import type { EpgChannel, EpgProgram } from '../../types/epg'; +import { logDebug, logError, logWarn } from '../logger'; export const isFulfilled = (input: PromiseSettledResult): input is PromiseFulfilledResult => { if (input.status === 'fulfilled') { return true; } - logDev(`An error occurred resolving a promise: `, input.reason); + logError('EpgController', `An error occurred resolving a promise: `, { error: input.reason }); return false; }; @@ -58,7 +58,7 @@ export default class EpgController { ?.transformProgram(program) // This quiets promise resolution errors in the console .catch((error) => { - logDev(error); + logDebug('EpgController', 'Failed to transform a program', { error, program }); return undefined; }), ), @@ -100,7 +100,7 @@ export default class EpgController { const service = getNamedModule(EpgService, scheduleType, false); if (!service) { - console.error(`No epg service was added for the ${scheduleType} schedule type`); + logWarn('EpgController', `No epg service was added for the ${scheduleType} schedule type`); } return service; diff --git a/packages/common/src/logger.ts b/packages/common/src/logger.ts new file mode 100644 index 000000000..7e099b958 --- /dev/null +++ b/packages/common/src/logger.ts @@ -0,0 +1,26 @@ +import { getAllModules } from './modules/container'; +import { LogLevel } from './services/logging/LogLevel'; +import LogTransporter from './services/logging/LogTransporter'; + +export type LogParams = { error?: unknown; [key: string]: unknown }; + +const wrapError = (error: unknown) => { + return error instanceof Error ? error : new Error(String(error)); +}; + +export const makeLogFn = + (logLevel: LogLevel) => + (scope: string, message: string, { error, ...extra }: LogParams = {}) => { + const transporters = getAllModules(LogTransporter); + + // call log on all transporters, the transporters should decide to handle the call or not + transporters.forEach((transporter) => { + transporter.log(logLevel, scope, message, extra, error ? wrapError(error) : undefined); + }); + }; + +export const logDebug = makeLogFn(LogLevel.DEBUG); +export const logInfo = makeLogFn(LogLevel.INFO); +export const logWarn = makeLogFn(LogLevel.WARN); +export const logError = makeLogFn(LogLevel.ERROR); +export const logFatal = makeLogFn(LogLevel.FATAL); diff --git a/packages/common/src/modules/container.ts b/packages/common/src/modules/container.ts index f98d06c26..e1438bd60 100644 --- a/packages/common/src/modules/container.ts +++ b/packages/common/src/modules/container.ts @@ -1,7 +1,5 @@ import { Container, injectable, type interfaces, inject } from 'inversify'; -import { logDev } from '../utils/common'; - export const container = new Container({ defaultScope: 'Singleton', skipBaseClassChecks: true }); export { injectable, inject }; @@ -17,6 +15,10 @@ export function getModule(constructorFunction: interfaces.ServiceIdentifier(constructorFunction: interfaces.ServiceIdentifier): T[] { + return container.getAll(constructorFunction); +} + export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required: false): T | undefined; export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required: true): T; export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null): T; @@ -40,7 +42,8 @@ export function getNamedModule(constructorFunction: interfaces.ServiceIdentif return; } - logDev('Error caught while initializing service', err); + // log service can't be used here + console.error('Error caught while initializing service', err); } } diff --git a/packages/common/src/services/ApiService.ts b/packages/common/src/services/ApiService.ts index 7acd22c66..167c524cc 100644 --- a/packages/common/src/services/ApiService.ts +++ b/packages/common/src/services/ApiService.ts @@ -11,6 +11,7 @@ import type { ContentList, GetContentSearchParams } from '../../types/content-li import type { AdSchedule } from '../../types/ad-schedule'; import type { EpisodeInSeries, EpisodesRes, EpisodesWithPagination, GetSeriesParams, Series } from '../../types/series'; import env from '../env'; +import { logError } from '../logger'; // change the values below to change the property used to look up the alternate image enum ImageProperty { @@ -38,7 +39,7 @@ export default class ApiService { const date = item[prop] as string | undefined; if (date && !isValid(new Date(date))) { - console.error(`Invalid "${prop}" date provided for the "${item.title}" media item`); + logError('ApiService', `Invalid "${prop}" date provided for the "${item.title}" media item`, { error: new Error('Invalid date') }); return undefined; } diff --git a/packages/common/src/services/FavoriteService.ts b/packages/common/src/services/FavoriteService.ts index ed833889a..9d299e311 100644 --- a/packages/common/src/services/FavoriteService.ts +++ b/packages/common/src/services/FavoriteService.ts @@ -1,13 +1,13 @@ import { inject, injectable } from 'inversify'; -import { object, array, string } from 'yup'; +import { array, object, string } from 'yup'; import type { Favorite, SerializedFavorite } from '../../types/favorite'; import type { PlaylistItem } from '../../types/playlist'; import type { Customer } from '../../types/account'; import { getNamedModule } from '../modules/container'; import { INTEGRATION_TYPE } from '../modules/types'; -import { logDev } from '../utils/common'; import { MAX_WATCHLIST_ITEMS_COUNT } from '../constants'; +import { logError } from '../logger'; import ApiService from './ApiService'; import StorageService from './StorageService'; @@ -66,7 +66,7 @@ export default class FavoriteService { return (playlistItems || []).map((item) => this.createFavorite(item)); } catch (error: unknown) { - logDev('Failed to get favorites', error); + logError('FavoriteService', 'Failed to get favorites', { error }); } return []; diff --git a/packages/common/src/services/SettingsService.ts b/packages/common/src/services/SettingsService.ts index 1a16fc72d..23f6bdbde 100644 --- a/packages/common/src/services/SettingsService.ts +++ b/packages/common/src/services/SettingsService.ts @@ -3,10 +3,10 @@ import ini from 'ini'; import { getI18n } from 'react-i18next'; import { CONFIG_FILE_STORAGE_KEY, CONFIG_QUERY_KEY, OTT_GLOBAL_PLAYER_ID } from '../constants'; -import { logDev } from '../utils/common'; import { AppError } from '../utils/error'; import type { Settings } from '../../types/settings'; import env from '../env'; +import { logDebug, logWarn } from '../logger'; import StorageService from './StorageService'; @@ -44,7 +44,7 @@ export default class SettingsService { return configKey; } - logDev(`Invalid app-config query param: ${configKey}`); + logWarn('SettingsService', `Invalid app-config query param: ${configKey}`); } // Yes this falls through from above to look up the stored value if the query string is invalid and that's OK @@ -56,7 +56,7 @@ export default class SettingsService { return storedSource; } - logDev('Invalid stored config: ' + storedSource); + logWarn('SettingsService', 'Invalid stored config: ' + storedSource); await this.storageService.removeItem(CONFIG_FILE_STORAGE_KEY); } @@ -78,8 +78,8 @@ export default class SettingsService { const settings = await fetch('/.webapp.ini') .then((result) => result.text()) .then((iniString) => ini.parse(iniString) as Settings) - .catch((e) => { - logDev(e); + .catch((error) => { + logDebug('SettingsService', 'Failed to fetch or parse the ini settings', { error }); // It's possible to not use the ini settings files, so an error doesn't have to be fatal return {} as Settings; }); @@ -106,7 +106,7 @@ export default class SettingsService { // The player key should be set if using the global ott player if (settings.playerId === OTT_GLOBAL_PLAYER_ID && !settings.playerLicenseKey) { - console.warn('Using Global OTT Player without setting player key. Some features, such as analytics, may not work correctly.'); + logWarn('SettingsService', 'Using Global OTT Player without setting player key. Some features, such as analytics, may not work correctly.'); } // This will result in an unusable app diff --git a/packages/common/src/services/WatchHistoryService.ts b/packages/common/src/services/WatchHistoryService.ts index 62e4408ac..518ea30d0 100644 --- a/packages/common/src/services/WatchHistoryService.ts +++ b/packages/common/src/services/WatchHistoryService.ts @@ -6,8 +6,8 @@ import type { SerializedWatchHistoryItem, WatchHistoryItem } from '../../types/w import type { Customer } from '../../types/account'; import { getNamedModule } from '../modules/container'; import { INTEGRATION_TYPE } from '../modules/types'; -import { logDev } from '../utils/common'; import { MAX_WATCHLIST_ITEMS_COUNT } from '../constants'; +import { logError } from '../logger'; import ApiService from './ApiService'; import StorageService from './StorageService'; @@ -107,7 +107,7 @@ export default class WatchHistoryService { }) .filter((item): item is WatchHistoryItem => Boolean(item)); } catch (error: unknown) { - logDev('Failed to get watch history items', error); + logError('WatchHistoryService', 'Failed to get watch history items', { error }); } return []; diff --git a/packages/common/src/services/epg/JWEpgService.ts b/packages/common/src/services/epg/JWEpgService.ts index dc49e13e8..13191102a 100644 --- a/packages/common/src/services/epg/JWEpgService.ts +++ b/packages/common/src/services/epg/JWEpgService.ts @@ -6,7 +6,7 @@ import EpgService from '../EpgService'; import type { PlaylistItem } from '../../../types/playlist'; import type { EpgProgram } from '../../../types/epg'; import { getDataOrThrow } from '../../utils/api'; -import { logDev } from '../../utils/common'; +import { logError, logWarn } from '../../logger'; const AUTHENTICATION_HEADER = 'API-KEY'; @@ -46,7 +46,7 @@ export default class JWEpgService extends EpgService { fetchSchedule = async (item: PlaylistItem) => { if (!item.scheduleUrl) { - logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); + logWarn('JWEpgService', 'Tried requesting a schedule for an item with missing `scheduleUrl`', { item }); return undefined; } @@ -66,7 +66,7 @@ export default class JWEpgService extends EpgService { return await getDataOrThrow(response); } catch (error: unknown) { if (error instanceof Error) { - logDev(`Fetch failed for EPG schedule: '${item.scheduleUrl}'`, error); + logError('JWEpgService', `Fetch failed for EPG schedule: '${item.scheduleUrl}'`, { error }); } } }; diff --git a/packages/common/src/services/epg/ViewNexaEpgService.ts b/packages/common/src/services/epg/ViewNexaEpgService.ts index b1ea1850c..b274d125d 100644 --- a/packages/common/src/services/epg/ViewNexaEpgService.ts +++ b/packages/common/src/services/epg/ViewNexaEpgService.ts @@ -4,8 +4,8 @@ import { injectable } from 'inversify'; import EpgService from '../EpgService'; import type { PlaylistItem } from '../../../types/playlist'; -import { logDev } from '../../utils/common'; import type { EpgProgram } from '../../../types/epg'; +import { logError, logWarn } from '../../logger'; const viewNexaEpgProgramSchema = object().shape({ 'episode-num': object().shape({ @@ -48,7 +48,7 @@ export default class ViewNexaEpgService extends EpgService { const scheduleUrl = item.scheduleUrl; if (!scheduleUrl) { - logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); + logWarn('ViewNexaEpgService', 'Tried requesting a schedule for an item with missing `scheduleUrl`', { item }); return undefined; } @@ -65,7 +65,7 @@ export default class ViewNexaEpgService extends EpgService { return schedule?.tv?.programme || []; } catch (error: unknown) { if (error instanceof Error) { - logDev(`Fetch failed for View Nexa EPG schedule: '${scheduleUrl}'`, error); + logError('ViewNexaEpgService', `Fetch failed for View Nexa EPG schedule: '${scheduleUrl}'`, { error }); } } }; diff --git a/packages/common/src/services/integrations/cleeng/CleengService.ts b/packages/common/src/services/integrations/cleeng/CleengService.ts index 59f963267..e2c253751 100644 --- a/packages/common/src/services/integrations/cleeng/CleengService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengService.ts @@ -3,12 +3,13 @@ import { object, string } from 'yup'; import { inject, injectable } from 'inversify'; import { BroadcastChannel } from 'broadcast-channel'; -import { IS_DEVELOPMENT_BUILD, logDev } from '../../../utils/common'; +import { IS_DEVELOPMENT_BUILD } from '../../../utils/common'; import { PromiseQueue } from '../../../utils/promiseQueue'; import type { AuthData } from '../../../../types/account'; import StorageService from '../../StorageService'; import { GET_CUSTOMER_IP } from '../../../modules/types'; import type { GetCustomerIP } from '../../../../types/get-customer-ip'; +import { logDebug, logError } from '../../../logger'; import type { GetLocalesResponse } from './types/account'; import type { Response } from './types/api'; @@ -130,12 +131,14 @@ export default class CleengService { }; } catch (error: unknown) { if (error instanceof Error) { - logDev('Failed to refresh accessToken', error); + logDebug('CleengService', 'Failed to refresh accessToken', { error }); // only logout when the token is expired or invalid, this prevents logging out users when the request failed due to a // network error or for aborted requests if (error.message.includes('Refresh token is expired or does not exist') || error.message.includes('Missing or invalid parameter')) { - if (!this.logoutCallback) logDev('logoutCallback is not set'); + if (!this.logoutCallback) { + logDebug('CleengService', 'logoutCallback is not set'); + } await this.logoutCallback?.(); } } @@ -300,7 +303,7 @@ export default class CleengService { return; } } catch (error: unknown) { - logDev('Failed to refresh tokens', error); + logDebug('CleengService', 'Failed to refresh tokens', { error }); } // if we are here, we didn't receive new tokens @@ -319,7 +322,7 @@ export default class CleengService { try { // token is already refreshing, let's wait for it if (this.isRefreshing) { - logDev('Token is already refreshing, waiting in queue...'); + logDebug('CleengService', 'Token is already refreshing, waiting in queue...'); return await this.queue.enqueue(); } @@ -330,7 +333,7 @@ export default class CleengService { await this.refreshTokens(this.tokens); } catch (error: unknown) { - logDev('Error caught while refreshing the access token', error); + logError('CleengService', 'Error caught while refreshing the access token', { error }); } }; diff --git a/packages/common/src/services/integrations/jwp/JWPProfileService.ts b/packages/common/src/services/integrations/jwp/JWPProfileService.ts index 5ed0a36ae..4d66c365f 100644 --- a/packages/common/src/services/integrations/jwp/JWPProfileService.ts +++ b/packages/common/src/services/integrations/jwp/JWPProfileService.ts @@ -5,6 +5,7 @@ import defaultAvatar from '@jwp/ott-theme/assets/profiles/default_avatar.png'; import ProfileService from '../ProfileService'; import StorageService from '../../StorageService'; import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../../types/profiles'; +import { logError } from '../../../logger'; @injectable() export default class JWPProfileService extends ProfileService { @@ -27,8 +28,8 @@ export default class JWPProfileService extends ProfileService { avatar_url: profile?.avatar_url || defaultAvatar, })) ?? [], }; - } catch { - console.error('Unable to list profiles.'); + } catch (error: unknown) { + logError('JWPProfileService', 'Unable to list profiles', { error }); return { canManageProfiles: false, collection: [], diff --git a/packages/common/src/services/logging/ConsoleTransporter.ts b/packages/common/src/services/logging/ConsoleTransporter.ts new file mode 100644 index 000000000..e00ea5bc9 --- /dev/null +++ b/packages/common/src/services/logging/ConsoleTransporter.ts @@ -0,0 +1,34 @@ +import { LogLevel } from './LogLevel'; +import LogTransporter from './LogTransporter'; + +export default class ConsoleTransporter extends LogTransporter { + constructor(private readonly logLevel: LogLevel) { + super(); + } + + override log(level: LogLevel, scope: string, message: string, extra?: Record, error?: Error) { + if (level < this.logLevel) return; + + switch (level) { + case LogLevel.ERROR: + case LogLevel.FATAL: + console.error(`${LogLevel[level]} [${scope}] ${message}`); + break; + case LogLevel.WARN: + console.warn(`${LogLevel[level]} [${scope}] ${message}`); + break; + default: + // eslint-disable-next-line no-console + console.log(`${LogLevel[level]} [${scope}] ${message}`); + } + + if (error) { + console.error(error); + } + + if (extra && Object.keys(extra).length > 0) { + // eslint-disable-next-line no-console + console.log(extra); + } + } +} diff --git a/packages/common/src/services/logging/LogLevel.ts b/packages/common/src/services/logging/LogLevel.ts new file mode 100644 index 000000000..96974b236 --- /dev/null +++ b/packages/common/src/services/logging/LogLevel.ts @@ -0,0 +1,14 @@ +export enum LogLevel { + // detailed information for diagnosing problems, typically only useful during development + DEBUG, + // general information about the application's operation, such as startup or shutdown messages + INFO, + // indications of potential problems or unusual situations that are not immediately harmful + WARN, + // errors that affect the functionality of the application but allow it to continue running + ERROR, + // severe errors that lead to the application's termination + FATAL, + // no logging will be performed. Used to disable logging completely + SILENT, +} diff --git a/packages/common/src/services/logging/LogTransporter.ts b/packages/common/src/services/logging/LogTransporter.ts new file mode 100644 index 000000000..4508010ac --- /dev/null +++ b/packages/common/src/services/logging/LogTransporter.ts @@ -0,0 +1,5 @@ +import type { LogLevel } from './LogLevel'; + +export default abstract class LogTransporter { + abstract log(level: LogLevel, scope: string, message: string, extra?: Record, error?: Error): void; +} diff --git a/packages/common/src/utils/common.ts b/packages/common/src/utils/common.ts index a3df6afb9..3756f8be2 100644 --- a/packages/common/src/utils/common.ts +++ b/packages/common/src/utils/common.ts @@ -70,7 +70,7 @@ export function hexToRgb(color: string) { * @link {https://stackoverflow.com/a/35970186/1790728} * @return {string} */ -export function calculateContrastColor(color: string) { +export function calculateContrastColor(color: string): string { const rgb = hexToRgb(color); if (!rgb) { @@ -94,16 +94,6 @@ export const IS_PREVIEW_MODE = __mode__ === 'preview'; // Production mode export const IS_PROD_MODE = __mode__ === 'prod'; -export function logDev(message: unknown, ...optionalParams: unknown[]) { - if ((IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && !IS_TEST_MODE) { - if (optionalParams.length > 0) { - console.info(message, optionalParams); - } else { - console.info(message); - } - } -} - export const isContentType = (item: PlaylistItem | Playlist, contentType: string) => item.contentType?.toLowerCase() === contentType.toLowerCase(); export const isTruthyCustomParamValue = (value: unknown): boolean => ['true', '1', 'yes', 'on'].includes(String(value)?.toLowerCase()); diff --git a/packages/common/src/utils/urlFormatting.ts b/packages/common/src/utils/urlFormatting.ts index fbf86362a..33ecc99b1 100644 --- a/packages/common/src/utils/urlFormatting.ts +++ b/packages/common/src/utils/urlFormatting.ts @@ -1,5 +1,6 @@ import type { PlaylistItem } from '../../types/playlist'; import { RELATIVE_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE, PATH_CONTENT_LIST } from '../paths'; +import { logWarn } from '../logger'; import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media'; @@ -65,7 +66,12 @@ export const createPath = (originalPath: Path, pathParams?: const paramValue = pathParams[paramName as keyof typeof pathParams]; if (!paramValue) { - if (!isOptional) console.warn('Missing param in path creation.', { path: originalPath, paramName }); + if (!isOptional) { + logWarn('urlFormatting', `Missing param in path creation`, { + path: originalPath, + paramName, + }); + } return ''; } @@ -100,7 +106,15 @@ export const mediaURL = ({ play?: boolean; episodeId?: string; }) => { - return createPath(PATH_MEDIA, { id: media.mediaid, title: slugify(media.title) }, { r: playlistId, play: play ? '1' : null, e: episodeId }); + return createPath( + PATH_MEDIA, + { id: media.mediaid, title: slugify(media.title) }, + { + r: playlistId, + play: play ? '1' : null, + e: episodeId, + }, + ); }; export const playlistURL = (id: string, title?: string) => { diff --git a/packages/common/test/mockService.ts b/packages/common/test/mockService.ts index c5361602f..f0b1b7176 100644 --- a/packages/common/test/mockService.ts +++ b/packages/common/test/mockService.ts @@ -13,8 +13,8 @@ export let mockedServices: ServiceMockEntry[] = []; const getName = (serviceIdentifier: interfaces.ServiceIdentifier) => serviceIdentifier instanceof Function ? serviceIdentifier.name : serviceIdentifier.toString(); -export const mockService = >(serviceIdentifier: interfaces.ServiceIdentifier, implementation: B) => { - if (mockedServices.some((mock) => mock.serviceIdentifier === serviceIdentifier)) { +export const mockService = >(serviceIdentifier: interfaces.ServiceIdentifier, implementation: B, override = false) => { + if (!override && mockedServices.some((mock) => mock.serviceIdentifier === serviceIdentifier)) { throw new Error(`There already is a mocked service for ${getName(serviceIdentifier)}`); } mockedServices = mockedServices.filter((a) => a.serviceIdentifier !== serviceIdentifier); @@ -34,17 +34,28 @@ afterEach(() => { vi.mock('@jwp/ott-common/src/modules/container', async () => { const actual = (await vi.importActual('@jwp/ott-common/src/modules/container')) as Record; + const getModule = (serviceIdentifier: interfaces.ServiceIdentifier) => { + const mockedService = mockedServices.find((current) => current.serviceIdentifier === serviceIdentifier); - return { - ...actual, - getModule: (serviceIdentifier: interfaces.ServiceIdentifier) => { - const mockedService = mockedServices.find((current) => current.serviceIdentifier === serviceIdentifier); + if (!mockedService) { + throw new Error(`Couldn't find mocked service for '${getName(serviceIdentifier)}'`); + } + + return mockedService.implementation; + }; - if (!mockedService) { - throw new Error(`Couldn't find mocked service for '${getName(serviceIdentifier)}'`); - } + const getAllModules = (serviceIdentifier: interfaces.ServiceIdentifier) => { + const mockedService = mockedServices.find((current) => current.serviceIdentifier === serviceIdentifier); - return mockedService.implementation; + return mockedService ? [mockedService.implementation] : []; + }; + + return { + ...actual, + getModule, + getAllModules, + getNamedModule: (serviceIdentifier: interfaces.ServiceIdentifier, _name: string) => { + return getModule(serviceIdentifier); }, }; }); diff --git a/packages/common/types/static.d.ts b/packages/common/types/static.d.ts index 58b6d72b1..573d973b6 100644 --- a/packages/common/types/static.d.ts +++ b/packages/common/types/static.d.ts @@ -16,3 +16,4 @@ declare module '*.xml?raw' { declare const __mode__: string; declare const __dev__: boolean; +declare const __debug__: boolean | undefined; diff --git a/packages/common/vitest.config.ts b/packages/common/vitest.config.ts index 938e56753..89bc2279c 100644 --- a/packages/common/vitest.config.ts +++ b/packages/common/vitest.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ define: { __mode__: '"test"', __dev__: true, + __debug__: process.env.APP_TEST_DEBUG === '1', }, }); diff --git a/packages/common/vitest.setup.ts b/packages/common/vitest.setup.ts index 1308d84dc..e120299ef 100644 --- a/packages/common/vitest.setup.ts +++ b/packages/common/vitest.setup.ts @@ -1,5 +1,20 @@ import 'vi-fetch/setup'; import 'reflect-metadata'; +import { mockService } from './test/mockService'; +import LogTransporter from './src/services/logging/LogTransporter'; +import ConsoleTransporter from './src/services/logging/ConsoleTransporter'; +import { LogLevel } from './src/services/logging/LogLevel'; + +beforeEach(() => { + mockService( + LogTransporter, + __debug__ + ? new ConsoleTransporter(LogLevel.DEBUG) + : { + log() {}, + }, + ); +}); // a really simple BroadcastChannel stub. Normally, a Broadcast channel would not call event listeners on the same // instance. But for testing purposes, that really doesn't matter... diff --git a/packages/hooks-react/src/useBootstrapApp.ts b/packages/hooks-react/src/useBootstrapApp.ts index a1a2874bc..6c366dc21 100644 --- a/packages/hooks-react/src/useBootstrapApp.ts +++ b/packages/hooks-react/src/useBootstrapApp.ts @@ -5,6 +5,7 @@ import { getModule } from '@jwp/ott-common/src/modules/container'; import AppController from '@jwp/ott-common/src/controllers/AppController'; import type { AppError } from '@jwp/ott-common/src/utils/error'; import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; +import { logDebug } from '@jwp/ott-common/src/logger'; const applicationController = getModule(AppController); @@ -26,7 +27,10 @@ export const useBootstrapApp = (url: string, onReady: OnReadyCallback) => { { refetchInterval: false, retry: 1, - onSettled: (query) => onReady(query?.config), + onSettled: (query) => { + logDebug('Bootstrap', 'Initialized application', { ...query }); + onReady(query?.config); + }, cacheTime: CACHE_TIME, staleTime: STALE_TIME, }, diff --git a/packages/hooks-react/src/useProfiles.ts b/packages/hooks-react/src/useProfiles.ts index ebda26e9a..b5416a1de 100644 --- a/packages/hooks-react/src/useProfiles.ts +++ b/packages/hooks-react/src/useProfiles.ts @@ -9,8 +9,8 @@ import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; import AccountController from '@jwp/ott-common/src/controllers/AccountController'; -import { logDev } from '@jwp/ott-common/src/utils/common'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { logError } from '@jwp/ott-common/src/logger'; export const useSelectProfile = (options?: { onSuccess: () => void; onError: () => void }) => { const accountController = getModule(AccountController, false); @@ -25,9 +25,9 @@ export const useSelectProfile = (options?: { onSuccess: () => void; onError: () await accountController?.loadUserData(); options?.onSuccess?.(); }, - onError: () => { + onError: (error) => { useProfileStore.setState({ selectingProfileAvatar: null }); - logDev('Unable to enter profile'); + logError('useProfiles', 'Unable to enter profile', { error }); options?.onError?.(); }, }); diff --git a/packages/hooks-react/vitest.config.ts b/packages/hooks-react/vitest.config.ts index 938e56753..89bc2279c 100644 --- a/packages/hooks-react/vitest.config.ts +++ b/packages/hooks-react/vitest.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ define: { __mode__: '"test"', __dev__: true, + __debug__: process.env.APP_TEST_DEBUG === '1', }, }); diff --git a/packages/hooks-react/vitest.setup.ts b/packages/hooks-react/vitest.setup.ts index 1be0f33d1..d961e132d 100644 --- a/packages/hooks-react/vitest.setup.ts +++ b/packages/hooks-react/vitest.setup.ts @@ -1,2 +1,17 @@ import 'vi-fetch/setup'; import 'reflect-metadata'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import LogTransporter from '@jwp/ott-common/src/services/logging/LogTransporter'; +import ConsoleTransporter from '@jwp/ott-common/src/services/logging/ConsoleTransporter'; +import { LogLevel } from '@jwp/ott-common/src/services/logging/LogLevel'; + +beforeEach(() => { + mockService( + LogTransporter, + __debug__ + ? new ConsoleTransporter(LogLevel.DEBUG) + : { + log() {}, + }, + ); +}); diff --git a/packages/ui-react/src/components/Account/Account.tsx b/packages/ui-react/src/components/Account/Account.tsx index 9752b12f9..b5145d268 100644 --- a/packages/ui-react/src/components/Account/Account.tsx +++ b/packages/ui-react/src/components/Account/Account.tsx @@ -8,7 +8,8 @@ import type { CustomFormField } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import AccountController from '@jwp/ott-common/src/controllers/AccountController'; -import { isTruthy, isTruthyCustomParamValue, logDev, testId } from '@jwp/ott-common/src/utils/common'; +import { isTruthy, isTruthyCustomParamValue, testId } from '@jwp/ott-common/src/utils/common'; +import { logError } from '@jwp/ott-common/src/logger'; import { formatConsents, formatConsentsFromValues, formatConsentsToRegisterFields, formatConsentValues } from '@jwp/ott-common/src/utils/collection'; import useToggle from '@jwp/ott-hooks-react/src/useToggle'; import Visibility from '@jwp/ott-theme/assets/icons/visibility.svg?react'; @@ -172,7 +173,7 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } } default: { formErrors.form = t('account.errors.unknown_error'); - logDev('Unknown error', error); + logError('Account', 'Unknown error', { error }); break; } } diff --git a/packages/ui-react/src/components/Player/Player.tsx b/packages/ui-react/src/components/Player/Player.tsx index bd5447a1d..f4083b63b 100644 --- a/packages/ui-react/src/components/Player/Player.tsx +++ b/packages/ui-react/src/components/Player/Player.tsx @@ -3,7 +3,8 @@ import type { AdSchedule } from '@jwp/ott-common/types/ad-schedule'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { deepCopy } from '@jwp/ott-common/src/utils/collection'; -import { logDev, testId } from '@jwp/ott-common/src/utils/common'; +import { testId } from '@jwp/ott-common/src/utils/common'; +import { logInfo } from '@jwp/ott-common/src/logger'; import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback'; import useOttAnalytics from '@jwp/ott-hooks-react/src/useOttAnalytics'; import { attachAnalyticsParams } from '@jwp/ott-common/src/utils/analytics'; @@ -155,7 +156,7 @@ const Player: React.FC = ({ // We already loaded this item if (currentItem && currentItem.mediaid === item.mediaid) { - logDev('Calling loadPlaylist with the same item, check the dependencies'); + logInfo('Player', 'Calling loadPlaylist with the same item, check the dependencies'); return; } diff --git a/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx b/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx index 6a2ee0d99..81fafbe75 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx @@ -8,8 +8,8 @@ import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; -import { logDev } from '@jwp/ott-common/src/utils/common'; import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; +import { logDebug, logError } from '@jwp/ott-common/src/logger'; import ResetPasswordForm from '../../../components/ResetPasswordForm/ResetPasswordForm'; import ForgotPasswordForm from '../../../components/ForgotPasswordForm/ForgotPasswordForm'; @@ -57,7 +57,7 @@ const ResetPassword: React.FC = ({ type }: Prop) => { const resetUrl = `${window.location.origin}/?u=edit-password`; try { if (!user?.email) { - logDev('invalid param email'); + logDebug('ResetPassword', 'invalid param email'); return; } @@ -68,7 +68,7 @@ const ResetPassword: React.FC = ({ type }: Prop) => { setResetPasswordSubmitting(false); navigate(modalURLFromLocation(location, 'send-confirmation')); } catch (error: unknown) { - logDev(error instanceof Error ? error.message : error); + logError('ResetPassword', 'Failed to reset password', { error }); } }; diff --git a/packages/ui-react/vitest.config.ts b/packages/ui-react/vitest.config.ts index 37e120fd9..5de6d74ac 100644 --- a/packages/ui-react/vitest.config.ts +++ b/packages/ui-react/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ css: true, }, define: { + __debug__: process.env.APP_TEST_DEBUG === '1', __mode__: '"test"', __dev__: true, }, diff --git a/packages/ui-react/vitest.setup.ts b/packages/ui-react/vitest.setup.ts index b6d92bcd5..9f505d53d 100644 --- a/packages/ui-react/vitest.setup.ts +++ b/packages/ui-react/vitest.setup.ts @@ -7,10 +7,27 @@ import type { ComponentType } from 'react'; import { fireEvent } from '@testing-library/react'; import * as matchers from 'vitest-axe/matchers'; import { expect } from 'vitest'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import LogTransporter from '@jwp/ott-common/src/services/logging/LogTransporter'; +import ConsoleTransporter from '@jwp/ott-common/src/services/logging/ConsoleTransporter'; +import { LogLevel } from '@jwp/ott-common/src/services/logging/LogLevel'; expect.extend(matchers); +beforeEach(() => { + mockService( + LogTransporter, + __debug__ + ? new ConsoleTransporter(LogLevel.DEBUG) + : { + log() {}, + }, + ); +}); + beforeAll(() => { + HTMLElement.prototype.scrollIntoView = vi.fn(); + // these methods don't exist in JSDOM: https://github.com/jsdom/jsdom/issues/3294 HTMLDialogElement.prototype.show = vi.fn().mockImplementation(function (this: HTMLDialogElement) { this.setAttribute('open', ''); diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index 9a351909f..6f50ea00a 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -4,6 +4,7 @@ import QueryProvider from '@jwp/ott-ui-react/src/containers/QueryProvider/QueryP import { ErrorPageWithoutTranslation } from '@jwp/ott-ui-react/src/components/ErrorPage/ErrorPage'; import LoadingOverlay from '@jwp/ott-ui-react/src/components/LoadingOverlay/LoadingOverlay'; import { AriaAnnouncerProvider } from '@jwp/ott-ui-react/src/containers/AnnouncementProvider/AnnoucementProvider'; +import { logError } from '@jwp/ott-common/src/logger'; import initI18n from './i18n/config'; import Root from './containers/Root/Root'; @@ -30,6 +31,8 @@ export default function App() { } if (i18nState.error) { + logError('App', 'Failed to load translations', { error: i18nState.error }); + // Don't be tempted to translate these strings. If i18n fails to load, translations won't work anyhow return ( (GET_CUSTOMER_IP).toConstantValue(async () => getOverrideIP()); +/** + * Log transporters + * + * Add custom log transporters by registering more modules to the LogTransporter type. The custom transporter must + * extend the LogTransporter class. + * + * @example + * ```ts + * container.bind(LogTransporter).toDynamicValue(() => new GoogleGTMTransporter(import.meta.env.DEV ? LogLevel.SILENT : LogLevel.WARN)); + * ``` + */ +container.bind(LogTransporter).toDynamicValue(() => new ConsoleTransporter(import.meta.env.DEV ? LogLevel.DEBUG : LogLevel.ERROR)); + /** * UI Component override * diff --git a/platforms/web/src/services/LocalStorageService.ts b/platforms/web/src/services/LocalStorageService.ts index a5cef2096..6c6f8c21e 100644 --- a/platforms/web/src/services/LocalStorageService.ts +++ b/platforms/web/src/services/LocalStorageService.ts @@ -1,5 +1,6 @@ import { injectable } from '@jwp/ott-common/src/modules/container'; import StorageService from '@jwp/ott-common/src/services/StorageService'; +import { logError } from '@jwp/ott-common/src/logger'; @injectable() export class LocalStorageService extends StorageService { @@ -19,7 +20,7 @@ export class LocalStorageService extends StorageService { return value && parse ? JSON.parse(value) : value; } catch (error: unknown) { - console.error(error); + logError('LocalStorageService', 'Failed to parse localStorage entry', { error }); } } @@ -27,7 +28,7 @@ export class LocalStorageService extends StorageService { try { window.localStorage.setItem(usePrefix ? this.getStorageKey(key) : key, value); } catch (error: unknown) { - console.error(error); + logError('LocalStorageService', 'Failed to store localStorage entry', { error }); } } @@ -35,7 +36,7 @@ export class LocalStorageService extends StorageService { try { window.localStorage.removeItem(this.getStorageKey(key)); } catch (error: unknown) { - console.error(error); + logError('LocalStorageService', 'Failed to remove localStorage entry', { error }); } } diff --git a/platforms/web/test/vitest.setup.ts b/platforms/web/test/vitest.setup.ts index 4bdde356b..c7619f254 100644 --- a/platforms/web/test/vitest.setup.ts +++ b/platforms/web/test/vitest.setup.ts @@ -4,9 +4,24 @@ import 'vi-fetch/setup'; import 'reflect-metadata'; import * as matchers from 'vitest-axe/matchers'; import { expect } from 'vitest'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import LogTransporter from '@jwp/ott-common/src/services/logging/LogTransporter'; +import ConsoleTransporter from '@jwp/ott-common/src/services/logging/ConsoleTransporter'; +import { LogLevel } from '@jwp/ott-common/src/services/logging/LogLevel'; expect.extend(matchers); +beforeEach(() => { + mockService( + LogTransporter, + __debug__ + ? new ConsoleTransporter(LogLevel.DEBUG) + : { + log() {}, + }, + ); +}); + // a really simple BroadcastChannel stub. Normally, a Broadcast channel would not call event listeners on the same // instance. But for testing purposes, that really doesn't matter... vi.stubGlobal( diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index a8b9eec78..30d7e6a28 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -57,8 +57,8 @@ export default ({ mode, command }: ConfigEnv): UserConfigExport => { // This is needed to do decorator transforms for ioc resolution to work for classes babel: { plugins: ['babel-plugin-transform-typescript-metadata', ['@babel/plugin-proposal-decorators', { legacy: true }]] }, }), - eslintPlugin({ emitError: mode === 'production' || mode === 'demo' || mode === 'preview' }), // Move linting to pre-build to match dashboard - StylelintPlugin(), + mode !== 'test' && eslintPlugin({ emitError: mode === 'production' || mode === 'demo' || mode === 'preview' }), // Move linting to pre-build to match dashboard + mode !== 'test' && StylelintPlugin(), svgr(), VitePWA({ registerType: 'autoUpdate', @@ -101,6 +101,7 @@ export default ({ mode, command }: ConfigEnv): UserConfigExport => { 'import.meta.env.APP_VERSION': JSON.stringify(process.env.npm_package_version), __mode__: JSON.stringify(mode), __dev__: process.env.NODE_ENV !== 'production', + __debug__: process.env.APP_TEST_DEBUG === '1', 'import.meta.env.APP_BODY_FONT': JSON.stringify(bodyFontsString), 'import.meta.env.APP_BODY_ALT_FONT': JSON.stringify(bodyAltFontsString), },