diff --git a/.happy/terraform/modules/cloudwatch-alarm/main.tf b/.happy/terraform/modules/cloudwatch-alarm/main.tf index ba8fcad49..d4fb6e56b 100644 --- a/.happy/terraform/modules/cloudwatch-alarm/main.tf +++ b/.happy/terraform/modules/cloudwatch-alarm/main.tf @@ -85,10 +85,25 @@ resource aws_cloudwatch_log_metric_filter data_workflows_plugin_update_successfu } } +resource aws_cloudwatch_log_metric_filter frontend_error { + name = "${var.stack_name}-frontend-error" + log_group_name = var.frontend_log_group_name + pattern = "{ $.level = \"error\" }" + count = var.metrics_enabled ? 1 : 0 + + metric_transformation { + name = "${var.stack_name}-frontend-error" + namespace = local.metrics_namespace + value = "1" + unit = "Count" + } +} + locals { backend_api_500_log_metric_name = var.metrics_enabled ? aws_cloudwatch_log_metric_filter.backend_api_500_log_metric[0].name : "backend_api_500_log_metric" data_workflows_metrics_update_successful_name = var.metrics_enabled ? aws_cloudwatch_log_metric_filter.data_workflows_metrics_update_successful[0].name : "data_workflows_metrics_update_successful" data_workflows_plugin_update_successful_name = var.metrics_enabled ? aws_cloudwatch_log_metric_filter.data_workflows_plugin_update_successful[0].name : "data_workflows_plugin_update_successful" + frontend_error_name = var.metrics_enabled ? aws_cloudwatch_log_metric_filter.frontend_error[0].name : "frontend_error" } module backend_api_500_alarm { @@ -257,3 +272,59 @@ module plugin_lambda_errors_alarm { }] tags = var.tags } + +module frontend_uncaught_error_alarm { + source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm" + version = "3.3.0" + + create_metric_alarm = var.alarms_enabled + alarm_name = "${var.stack_name}-frontend-uncaught-error-alarm" + alarm_description = "Errors that are not caught by any error handling on the frontend" + alarm_actions = [local.alarm_sns_arn] + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + datapoints_to_alarm = 1 + metric_query = [ + { + id = "error_sum" + return_data = true + expression = "SUM(METRICS())" + label = "Total Error Count" + }, + + { + id = "frontend_uncaught_error" + metric = [{ + namespace = "AWS/RUM" + metric_name = "JsErrorCount" + period = local.period + stat = "Sum" + unit = "Count" + + dimensions = { + application_name = var.frontend_rum_app_name + } + }] + } + ] + tags = var.tags +} + +module frontend_error_alarm { + source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm" + version = "3.3.0" + + alarm_actions = [local.alarm_sns_arn] + alarm_description = "Errors that happen on the frontend that are handled by some form of error handling" + alarm_name = "${var.stack_name}-frontend-error-alarm" + comparison_operator = "GreaterThanOrEqualToThreshold" + create_metric_alarm = var.alarms_enabled + datapoints_to_alarm = 1 + evaluation_periods = 1 + metric_name = local.frontend_error_name + namespace = local.metrics_namespace + period = local.period + statistic = "Sum" + tags = var.tags + threshold = 2 +} diff --git a/.happy/terraform/modules/cloudwatch-alarm/variables.tf b/.happy/terraform/modules/cloudwatch-alarm/variables.tf index 902183ec5..b2a8bf04b 100644 --- a/.happy/terraform/modules/cloudwatch-alarm/variables.tf +++ b/.happy/terraform/modules/cloudwatch-alarm/variables.tf @@ -48,3 +48,13 @@ variable stack_name { variable tags { type = map(string) } + +variable frontend_log_group_name { + type = string + description = "Log group name for frontend" +} + +variable frontend_rum_app_name { + type = string + description = "App name for frontend RUM monitor" +} diff --git a/.happy/terraform/modules/ecs-stack/main.tf b/.happy/terraform/modules/ecs-stack/main.tf index 26769b464..582eb3fcd 100644 --- a/.happy/terraform/modules/ecs-stack/main.tf +++ b/.happy/terraform/modules/ecs-stack/main.tf @@ -670,7 +670,8 @@ resource "aws_lambda_function_event_invoke_config" "async-config" { } locals { - monitoring_enabled = var.env == "prod" || var.env == "staging" + # TODO revert this after testing + monitoring_enabled = var.env == "prod" || var.env == "staging" || var.env == "dev" } module alarm_and_monitoring { @@ -685,4 +686,6 @@ module alarm_and_monitoring { data_workflows_lambda_log_group_name = module.data_workflows_lambda.cloudwatch_log_group_name plugins_lambda_function_name = module.plugins_lambda.function_name tags = var.tags + frontend_log_group_name = module.frontend_service.cloudwatch_log_group_name + frontend_rum_app_name = local.cloudwatch_rum_config.app_name } diff --git a/.happy/terraform/modules/service/outputs.tf b/.happy/terraform/modules/service/outputs.tf index e69de29bb..a57f19dae 100644 --- a/.happy/terraform/modules/service/outputs.tf +++ b/.happy/terraform/modules/service/outputs.tf @@ -0,0 +1,4 @@ +output cloudwatch_log_group_name { + description = "The name of the Cloudwatch Log Group" + value = aws_cloudwatch_log_group.cloud_watch_logs_group.name +} diff --git a/frontend/src/components/HomePage/HomePage.tsx b/frontend/src/components/HomePage/HomePage.tsx index fb2dee9e7..9041d5c83 100644 --- a/frontend/src/components/HomePage/HomePage.tsx +++ b/frontend/src/components/HomePage/HomePage.tsx @@ -4,16 +4,44 @@ import { Link } from '@/components/Link'; import { SearchBar } from '@/components/SearchBar'; import { SearchSection } from '@/components/SearchSection'; import { useOpenSearchPage } from '@/hooks/useOpenSearchPage'; +import { Logger } from '@/utils'; import { FeaturedPlugins } from './FeaturedPlugins'; import { HomePageLayout } from './HomePageLayout'; +const logger = new Logger('HomePage.tsx'); + export function HomePage() { const { t } = useTranslation(['homePage', 'common']); const openSearchPage = useOpenSearchPage(); return ( +
+ + + +
+ ([ 'writerSaveLayers', ]); +const logger = new Logger('MetadataListMetadataItem.ts'); + /** * Component for rendering a metadata value. */ @@ -117,7 +121,16 @@ export function MetadataListMetadataItem({ const { data } = await spdxLicenseDataAPI.get(''); return data.licenses; }, - { enabled: metadataKey === 'license' }, + { + enabled: metadataKey === 'license', + onError(err) { + logger.error({ + message: + 'Error fetching spdx license data for MetadataListMetadataItem', + error: getErrorMessage(err), + }); + }, + }, ); const isOsiApproved = useMemo( diff --git a/frontend/src/hooks/usePageTransitions.ts b/frontend/src/hooks/usePageTransitions.ts index 5187f4a0e..a740ae947 100644 --- a/frontend/src/hooks/usePageTransitions.ts +++ b/frontend/src/hooks/usePageTransitions.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { loadingStore } from '@/store/loading'; import { pageTransitionsStore } from '@/store/pageTransitions'; import { Logger } from '@/utils'; +import { getErrorMessage } from '@/utils/error'; import { usePageUtils } from './usePageUtils'; @@ -62,7 +63,11 @@ export function usePageTransitions() { } const onError = (error: Error, url: string, event: RouteEvent) => { - logger.error('Error loading route:', error); + logger.error({ + message: 'Error loading route:', + error: getErrorMessage(error), + }); + onFinishLoading(url, event); }; diff --git a/frontend/src/hooks/usePlausible.ts b/frontend/src/hooks/usePlausible.ts index b91469093..c1ee071fd 100644 --- a/frontend/src/hooks/usePlausible.ts +++ b/frontend/src/hooks/usePlausible.ts @@ -84,7 +84,11 @@ export function usePlausible() { event: E, ...payload: Events[E][] ) { - logger.debug('Plausible event:', { event, payload }); + logger.debug({ + message: 'Plausible event', + event, + payload, + }); plausible(event, { props: payload[0], diff --git a/frontend/src/hooks/usePluginMetrics.ts b/frontend/src/hooks/usePluginMetrics.ts index 43610b325..def5a5fb0 100644 --- a/frontend/src/hooks/usePluginMetrics.ts +++ b/frontend/src/hooks/usePluginMetrics.ts @@ -2,8 +2,12 @@ import { AxiosError } from 'axios'; import { useQuery, UseQueryOptions } from 'react-query'; import { PluginMetrics } from '@/types/metrics'; +import { Logger } from '@/utils'; +import { getErrorMessage } from '@/utils/error'; import { hubAPI } from '@/utils/HubAPIClient'; +const logger = new Logger('usePluginMetrics'); + export function usePluginMetrics( plugin?: string, options?: UseQueryOptions, @@ -15,6 +19,14 @@ export function usePluginMetrics( () => (plugin ? hubAPI.getPluginMetrics(plugin) : undefined), { enabled, + onError(err) { + options?.onError?.(err); + + logger.error({ + message: 'Failed to fetch plugin metrics', + error: getErrorMessage(err), + }); + }, ...options, }, ); diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 2c3fcdec2..b1d8394a3 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,21 +1,22 @@ -import axios from 'axios'; import Head from 'next/head'; import { useTranslation } from 'next-i18next'; import { ReactNode } from 'react'; -import { z } from 'zod'; import { ErrorMessage } from '@/components/ErrorMessage'; import { HomePage, HomePageProvider } from '@/components/HomePage'; import { PluginSectionsResponse, PluginSectionType } from '@/types'; +import { Logger } from '@/utils'; +import { getErrorMessage } from '@/utils/error'; import { hubAPI } from '@/utils/HubAPIClient'; import { getServerSidePropsHandler } from '@/utils/ssr'; -import { getZodErrorMessage } from '@/utils/validate'; interface Props { error?: string; pluginSections?: PluginSectionsResponse; } +const logger = new Logger('pages/index.ts'); + export const getServerSideProps = getServerSidePropsHandler({ async getProps() { const props: Props = {}; @@ -27,13 +28,12 @@ export const getServerSideProps = getServerSidePropsHandler({ PluginSectionType.recentlyUpdated, ]); } catch (err) { - if (axios.isAxiosError(err)) { - props.error = err.message; - } + props.error = getErrorMessage(err); - if (err instanceof z.ZodError) { - props.error = getZodErrorMessage(err); - } + logger.error({ + message: 'Failed to fetch plugin sections', + error: props.error, + }); } return { props }; diff --git a/frontend/src/pages/plugins/[name].tsx b/frontend/src/pages/plugins/[name].tsx index 25aba6215..bdfc62f19 100644 --- a/frontend/src/pages/plugins/[name].tsx +++ b/frontend/src/pages/plugins/[name].tsx @@ -1,8 +1,6 @@ -import { AxiosError } from 'axios'; import Head from 'next/head'; import { useTranslation } from 'next-i18next'; import { ParsedUrlQuery } from 'node:querystring'; -import { z } from 'zod'; import { ErrorMessage } from '@/components/ErrorMessage'; import { PageMetadata } from '@/components/PageMetadata'; @@ -11,10 +9,10 @@ import { DEFAULT_REPO_DATA } from '@/constants/plugin'; import { useLoadingState } from '@/context/loading'; import { PluginStateProvider } from '@/context/plugin'; import { PluginData } from '@/types'; -import { createUrl, fetchRepoData, FetchRepoDataResult } from '@/utils'; +import { createUrl, fetchRepoData, FetchRepoDataResult, Logger } from '@/utils'; +import { getErrorMessage } from '@/utils/error'; import { hubAPI } from '@/utils/HubAPIClient'; import { getServerSidePropsHandler } from '@/utils/ssr'; -import { getZodErrorMessage } from '@/utils/validate'; /** * Interface for parameters in URL. @@ -30,9 +28,7 @@ interface BaseProps { type Props = FetchRepoDataResult & BaseProps; -function isAxiosError(error: unknown): error is AxiosError { - return !!(error as AxiosError).isAxiosError; -} +const logger = new Logger('pages/plugins/[name].tsx'); export const getServerSideProps = getServerSidePropsHandler({ /** @@ -45,20 +41,32 @@ export const getServerSideProps = getServerSidePropsHandler({ repo: DEFAULT_REPO_DATA, }; - try { - const data = await hubAPI.getPlugin(name); - props.plugin = data; + let codeRepo = ''; - const result = await fetchRepoData(data.code_repository); - Object.assign(props, result); + try { + const plugin = await hubAPI.getPlugin(name); + codeRepo = plugin.code_repository; + props.plugin = plugin; } catch (err) { - if (isAxiosError(err) || err instanceof Error) { - props.error = err.message; - } + props.error = getErrorMessage(err); + logger.error({ + message: 'Failed to fetch plugin data', + plugin: name, + error: props.error, + }); + + return { props }; + } - if (err instanceof z.ZodError) { - props.error = getZodErrorMessage(err); - } + const repoData = await fetchRepoData(codeRepo); + Object.assign(repoData, await fetchRepoData(codeRepo)); + + if (props.repoFetchError) { + logger.error({ + message: 'Failed to fetch repo data', + plugin: name, + error: props.error, + }); } return { props }; diff --git a/frontend/src/pages/plugins/index.tsx b/frontend/src/pages/plugins/index.tsx index 61c7f4f1c..bc2dc2459 100644 --- a/frontend/src/pages/plugins/index.tsx +++ b/frontend/src/pages/plugins/index.tsx @@ -1,7 +1,5 @@ -import axios from 'axios'; import Head from 'next/head'; import { useTranslation } from 'next-i18next'; -import { z } from 'zod'; import { ErrorMessage } from '@/components/ErrorMessage'; import { NotFoundPage } from '@/components/NotFoundPage'; @@ -9,10 +7,11 @@ import { SearchPage } from '@/components/SearchPage'; import { SearchStoreProvider } from '@/store/search/context'; import { SpdxLicenseData, SpdxLicenseResponse } from '@/store/search/types'; import { PluginIndexData } from '@/types'; +import { Logger } from '@/utils'; +import { getErrorMessage } from '@/utils/error'; import { hubAPI } from '@/utils/HubAPIClient'; import { spdxLicenseDataAPI } from '@/utils/spdx'; import { getServerSidePropsHandler } from '@/utils/ssr'; -import { getZodErrorMessage } from '@/utils/validate'; interface Props { error?: string; @@ -21,6 +20,8 @@ interface Props { status?: number; } +const logger = new Logger('pages/plugins/index.tsx'); + export const getServerSideProps = getServerSidePropsHandler({ async getProps() { const props: Props = { @@ -29,19 +30,28 @@ export const getServerSideProps = getServerSidePropsHandler({ try { const index = await hubAPI.getPluginIndex(); + props.index = index; + } catch (err) { + props.error = getErrorMessage(err); + + logger.error({ + message: 'Failed to plugin index', + error: props.error, + }); + } + + try { const { data: { licenses }, } = await spdxLicenseDataAPI.get(''); - - Object.assign(props, { index, licenses }); + props.licenses = licenses; } catch (err) { - if (axios.isAxiosError(err)) { - props.error = err.message; - } + props.error = getErrorMessage(err); - if (err instanceof z.ZodError) { - props.error = getZodErrorMessage(err); - } + logger.error({ + message: 'Failed to fetch spdx license data', + error: props.error, + }); } return { props }; diff --git a/frontend/src/pages/robots.txt.ts b/frontend/src/pages/robots.txt.ts index 2b0313dba..ddabe4075 100644 --- a/frontend/src/pages/robots.txt.ts +++ b/frontend/src/pages/robots.txt.ts @@ -1,6 +1,7 @@ import { GetServerSideProps } from 'next'; import { Logger } from '@/utils'; +import { getErrorMessage } from '@/utils/error'; const logger = new Logger('robots.txt.ts'); @@ -29,7 +30,10 @@ export const getServerSideProps: GetServerSideProps = async ({ res }) => { res.write(robotsTxt); res.end(); } catch (err) { - logger.error('Unable to fetch plugin list:', err); + logger.error({ + message: 'Unable to fetch plugin list:', + error: getErrorMessage(err), + }); } return { props: {} }; diff --git a/frontend/src/store/search/context.tsx b/frontend/src/store/search/context.tsx index 080d968dc..5d589f504 100644 --- a/frontend/src/store/search/context.tsx +++ b/frontend/src/store/search/context.tsx @@ -81,7 +81,9 @@ export function SearchStoreProvider({ ), ), ).current; - const resultsStore = useRef(getResultsStore(searchStore)).current; + const resultsStore = useRef( + getResultsStore(searchStore, router.asPath), + ).current; // Initialize state once on initial render. This needs to happen outside of an // effect so that it runs before any nested effects. diff --git a/frontend/src/store/search/queryParameters.ts b/frontend/src/store/search/queryParameters.ts index a51a114c8..e2a3c1596 100644 --- a/frontend/src/store/search/queryParameters.ts +++ b/frontend/src/store/search/queryParameters.ts @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ -import { isObject, set } from 'lodash'; +import { set } from 'lodash'; import { snapshot, subscribe } from 'valtio'; import { BEGINNING_PAGE, RESULTS_PER_PAGE } from '@/constants/search'; @@ -9,50 +9,7 @@ import { createUrl, replaceUrlState } from '@/utils'; import { SearchQueryParams, SearchSortType } from './constants'; import { PluginSearchResultStore } from './results.store'; import type { PluginSearchStore } from './search.store'; - -export const PARAM_KEY_MAP: Record = { - operatingSystems: 'operatingSystem', - pythonVersion: 'python', -}; - -export const PARAM_VALUE_MAP: Record = { - openSource: 'oss', -}; - -interface ForEachFilterParamCallbackOptions { - filterKey: string; - key: string; - state: unknown; - stateKey: string; - value: string; -} - -/** - * Utility function for iterating through all filter states with relevant data. - * This includes the state keys, parameter names / values, and the state value. - * - * @param callback The callback to call :) - */ -function forEachFilterParam( - searchStore: PluginSearchStore, - callback: (options: ForEachFilterParamCallbackOptions) => void, -) { - for (const [filterKey, store] of Object.entries(searchStore.filters)) { - if (isObject(store)) { - for (const [stateKey, state] of Object.entries(store)) { - const key = PARAM_KEY_MAP[filterKey] ?? filterKey; - const value = PARAM_VALUE_MAP[stateKey] ?? stateKey; - callback({ - state: state as boolean, - filterKey, - key, - stateKey, - value, - }); - } - } - } -} +import { forEachFilterParam } from './utils'; /** * Parses the URL query parameters for initial state. This should only happen @@ -150,58 +107,20 @@ export function initStateFromQueryParameters( * * @param initialLoad Whether this is the first time the page is loading. */ -function updateQueryParameters( - searchStore: PluginSearchStore, - initialLoad?: boolean, -) { +function updateQueryParameters({ + initialLoad, + searchStore, +}: { + initialLoad?: boolean; + searchStore: PluginSearchStore; +}) { const url = new URL(window.location.href); - const params = url.searchParams; - // Draft parameter object to store updated parameter values. - const nextParams = new URLSearchParams(); - - // Set of parameters that should not transfer to the draft parameter object. - // This is necessary so that the URL maintains non state related parameters in - // the URL. - const blockedParams = new Set([ - ...Object.values(SearchQueryParams), - ...Object.keys(searchStore.filters).map((key) => PARAM_KEY_MAP[key] ?? key), - ]); - - for (const key of params.keys()) { - if (!blockedParams.has(key)) { - for (const value of params.getAll(key)) { - nextParams.append(key, value); - } - } - } - - // Search query - const { query } = searchStore.search; - if (query) { - nextParams.set(SearchQueryParams.Search, query); - } - - // Sort type - // Don't set sort type on initial load unless a value is already specified. - if (!initialLoad || params.get(SearchQueryParams.Sort)) { - const { sort } = searchStore; - nextParams.set(SearchQueryParams.Sort, sort); - } - - // Filters - forEachFilterParam(searchStore, ({ key, value, state }) => { - if (typeof state === 'boolean' && state) { - nextParams.append(key, value); - } + const params = searchStore.getSearchParams({ + initialLoad, + path: url.pathname + url.search, }); - // Current page. - // Don't set query parameter on initial load unless a value is already specified. - if (!initialLoad || params.get(SearchQueryParams.Page)) { - nextParams.set(SearchQueryParams.Page, String(searchStore.page)); - } - - url.search = nextParams.toString(); + url.search = params.toString(); if (window.location.href !== url.href) { replaceUrlState(url); } @@ -216,11 +135,10 @@ function updateQueryParameters( export function startQueryParameterListener( searchStore: PluginSearchStore, ): () => void { - updateQueryParameters(searchStore, true); + updateQueryParameters({ searchStore, initialLoad: true }); - const unsubscribe = subscribe( - searchStore, - updateQueryParameters.bind(null, searchStore, false), + const unsubscribe = subscribe(searchStore, () => + updateQueryParameters({ searchStore }), ); return unsubscribe; } diff --git a/frontend/src/store/search/results.store.ts b/frontend/src/store/search/results.store.ts index 9da0faba7..ce2e2b173 100644 --- a/frontend/src/store/search/results.store.ts +++ b/frontend/src/store/search/results.store.ts @@ -3,6 +3,7 @@ import { derive } from 'valtio/utils'; import { RESULTS_PER_PAGE } from '@/constants/search'; import { Logger, measureExecution } from '@/utils'; +import { SearchQueryParams } from './constants'; import type { PluginSearchStore } from './search.store'; import { SearchResult } from './search.types'; import { sortResults } from './sorters'; @@ -32,6 +33,7 @@ function getPaginationResults(results: SearchResult[], page: number) { export function getResultsStore( searchStore: PluginSearchStore, + path: string, ): PluginSearchResultStore { return derive({ results: (get) => { @@ -39,6 +41,7 @@ export function getResultsStore( const { query, index } = state.search; let results: SearchResult[]; + let searchDuration = '0 ms'; // Return full list of plugins if the engine or query aren't defined. if (!query) { @@ -54,13 +57,8 @@ export function getResultsStore( searchStore.search.search(), ); - logger.debug('plugin search:', { - query, - result, - duration, - }); - results = result; + searchDuration = duration; } results = state.filters.filterResults(results); @@ -69,6 +67,25 @@ export function getResultsStore( const totalPlugins = results.length; const totalPages = Math.ceil(totalPlugins / RESULTS_PER_PAGE); + const params = state.getSearchParams({ path }); + params.delete(SearchQueryParams.Search); + const enabledState: Record = {}; + + for (const key of params.keys()) { + enabledState[key] = params.getAll(key); + } + + logger.debug({ + message: 'plugin search', + // only show existence of query since we don't want to log query in case + // of user accidentally pasting PII. + hasQuery: !!query, + enabledState, + searchDuration, + totalPages, + totalPlugins, + }); + return { totalPlugins, totalPages, diff --git a/frontend/src/store/search/search.store.ts b/frontend/src/store/search/search.store.ts index f9292fde4..632254b46 100644 --- a/frontend/src/store/search/search.store.ts +++ b/frontend/src/store/search/search.store.ts @@ -7,12 +7,14 @@ import { subscribeKey } from 'valtio/utils'; import { BEGINNING_PAGE } from '@/constants/search'; import { PluginIndexData } from '@/types'; +import { createUrl } from '@/utils'; -import { DEFAULT_SORT_TYPE } from './constants'; +import { DEFAULT_SORT_TYPE, SearchQueryParams } from './constants'; import { FuseSearchEngine } from './engines'; import { SearchFilterStore } from './filter.store'; import { SearchEngine, SearchResult } from './search.types'; import { Resettable } from './types'; +import { forEachFilterParam, PARAM_KEY_MAP } from './utils'; export class SearchEngineStore implements Resettable { engine: SearchEngine; @@ -71,6 +73,62 @@ export class PluginSearchStore implements Resettable { return unsubscribe; } + + getSearchParams({ + initialLoad, + path, + }: { + initialLoad?: boolean; + path: string; + }): URLSearchParams { + const url = createUrl(path); + const params = url.searchParams; + // Draft parameter object to store updated parameter values. + const nextParams = new URLSearchParams(); + + // Set of parameters that should not transfer to the draft parameter object. + // This is necessary so that the URL maintains non state related parameters in + // the URL. + const blockedParams = new Set([ + ...Object.values(SearchQueryParams), + ...Object.keys(this.filters).map((key) => PARAM_KEY_MAP[key] ?? key), + ]); + + for (const key of params.keys()) { + if (!blockedParams.has(key)) { + for (const value of params.getAll(key)) { + nextParams.append(key, value); + } + } + } + + // Search query + const { query } = this.search; + if (query) { + nextParams.set(SearchQueryParams.Search, query); + } + + // Sort type + // Don't set sort type on initial load unless a value is already specified. + if (!initialLoad || params.get(SearchQueryParams.Sort)) { + nextParams.set(SearchQueryParams.Sort, this.sort); + } + + // Filters + forEachFilterParam(this, ({ key, value, state }) => { + if (typeof state === 'boolean' && state) { + nextParams.append(key, value); + } + }); + + // Current page. + // Don't set query parameter on initial load unless a value is already specified. + if (!initialLoad || params.get(SearchQueryParams.Page)) { + nextParams.set(SearchQueryParams.Page, String(this.page)); + } + + return nextParams; + } } export type FilterKey = NonFunctionKeys; diff --git a/frontend/src/store/search/utils.ts b/frontend/src/store/search/utils.ts new file mode 100644 index 000000000..ac4880d2b --- /dev/null +++ b/frontend/src/store/search/utils.ts @@ -0,0 +1,46 @@ +import _ from 'lodash'; + +import type { PluginSearchStore } from './search.store'; + +export const PARAM_KEY_MAP: Record = { + operatingSystems: 'operatingSystem', + pythonVersion: 'python', +}; + +export const PARAM_VALUE_MAP: Record = { + openSource: 'oss', +}; + +interface ForEachFilterParamCallbackOptions { + filterKey: string; + key: string; + state: unknown; + stateKey: string; + value: string; +} +/** + * Utility function for iterating through all filter states with relevant data. + * This includes the state keys, parameter names / values, and the state value. + * + * @param callback The callback to call :) + */ +export function forEachFilterParam( + searchStore: PluginSearchStore, + callback: (options: ForEachFilterParamCallbackOptions) => void, +) { + for (const [filterKey, store] of Object.entries(searchStore.filters)) { + if (_.isObject(store)) { + for (const [stateKey, state] of Object.entries(store)) { + const key = PARAM_KEY_MAP[filterKey] ?? filterKey; + const value = PARAM_VALUE_MAP[stateKey] ?? stateKey; + callback({ + state: state as boolean, + filterKey, + key, + stateKey, + value, + }); + } + } + } +} diff --git a/frontend/src/utils/HubAPIClient.ts b/frontend/src/utils/HubAPIClient.ts index b1488463c..6a150aa76 100644 --- a/frontend/src/utils/HubAPIClient.ts +++ b/frontend/src/utils/HubAPIClient.ts @@ -92,22 +92,25 @@ class HubAPIClient { }); if (SERVER) { - logger.info(`method=${method} url=${url} status=${status}`); + logger.info({ + path, + method, + url, + status, + }); } return data; } catch (err) { - if (SERVER && axios.isAxiosError(err)) { - logger.error( - [ - `method=${method}`, - `url=${path}`, - err.response?.status ? `status=${err.response.status}` : '', - `error="${err.message}"`, - ] - .filter(Boolean) - .join(' '), - ); + if (axios.isAxiosError(err)) { + logger.error({ + message: 'Error sending request', + error: err.message, + method, + path, + url, + ...(err.response?.status ? { status: err.response.status } : {}), + }); } throw err; diff --git a/frontend/src/utils/error.ts b/frontend/src/utils/error.ts index c9b0948bf..7f35f5d9c 100644 --- a/frontend/src/utils/error.ts +++ b/frontend/src/utils/error.ts @@ -1,5 +1,18 @@ import axios from 'axios'; -import { NextApiResponse } from 'next'; +import { z } from 'zod'; + +/** + * Get human friendly version of zod error message that describes what + * properties are failing and what types are expected. + */ +function getZodErrorMessage(error: z.ZodError) { + return [ + 'Received invalid data:', + ...error.issues.map( + (issue) => ` ${issue.path.join('.')}: ${issue.message}`, + ), + ].join('\n'); +} export function getErrorMessage(error: unknown): string { if (axios.isAxiosError(error)) { @@ -10,17 +23,9 @@ export function getErrorMessage(error: unknown): string { return error.message; } - return String(error); -} - -export async function apiErrorWrapper( - res: NextApiResponse, - callback: () => Promise, -) { - try { - await callback(); - } catch (err) { - const status = axios.isAxiosError(err) ? err.response?.status : null; - res.status(status ?? 500).send(getErrorMessage(err)); + if (error instanceof z.ZodError) { + return getZodErrorMessage(error); } + + return String(error); } diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts index 98eb53f60..e99a2d118 100644 --- a/frontend/src/utils/logger.ts +++ b/frontend/src/utils/logger.ts @@ -1,10 +1,8 @@ -/* - eslint-disable - @typescript-eslint/no-explicit-any, - @typescript-eslint/no-unsafe-argument, - max-classes-per-file, - no-console, -*/ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable no-console */ import axios from 'axios'; @@ -129,27 +127,29 @@ export class Logger { } private logMessages(level: LogLevel, messages: any[]): void { - console[level](...this.formatMessages(messages)); + console[level](...this.formatMessages(level, messages)); } - private formatMessages(messages: any[]): any[] { + private formatMessages(level: LogLevel, messages: any[]): any[] { const date = new Date(); - return [ - `date=${date.toISOString()}`, - this.name && `file=${this.name}`, - `type=${SERVER ? 'server' : 'client'}`, - `node_env=${process.env.NODE_ENV}`, - `env=${process.env.ENV}`, - ] - .filter(Boolean) - .concat(messages); + return messages.map((message) => + JSON.stringify({ + level, + date: date.toISOString(), + type: SERVER ? 'server' : 'client', + node_env: process.env.NODE_ENV, + env: process.env.ENV, + ...(this.name && { name: this.name }), + ...message, + }), + ); } private getLogEntries(level: LogLevel, messages: any[]): LogEntry { return { level, - messages: this.formatMessages(messages), + messages: this.formatMessages(level, messages), }; } } diff --git a/frontend/src/utils/sitemap.server.ts b/frontend/src/utils/sitemap.server.ts index 0361f1478..e98f9f47b 100644 --- a/frontend/src/utils/sitemap.server.ts +++ b/frontend/src/utils/sitemap.server.ts @@ -9,6 +9,8 @@ import { createUrl, Logger } from '@/utils'; import { hubAPI } from '@/utils/HubAPIClient'; import { getBuildManifest, getPreRenderManifest } from '@/utils/next'; +import { getErrorMessage } from './error'; + const logger = new Logger('sitemap.ts'); // URLs to exclude from the sitemap.xml file. @@ -55,7 +57,10 @@ function getHubEntries(): SitemapEntry[] { return entries; } catch (err) { - logger.error('Unable to read Next.js build manifest:', err); + logger.error({ + message: 'Unable to read Next.js build manifest', + error: getErrorMessage(err), + }); } return []; @@ -80,7 +85,10 @@ async function getPluginEntries(): Promise { }; }); } catch (err) { - logger.error('Unable to fetch plugin list:', err); + logger.error({ + message: 'Unable to fetch plugin list:', + error: getErrorMessage(err), + }); } return []; diff --git a/frontend/src/utils/validate/error.ts b/frontend/src/utils/validate/error.ts deleted file mode 100644 index cb89f3e8b..000000000 --- a/frontend/src/utils/validate/error.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod'; - -/** - * Get human friendly version of zod error message that describes what - * properties are failing and what types are expected. - */ -export function getZodErrorMessage(error: z.ZodError) { - return [ - 'Received invalid data:', - ...error.issues.map( - (issue) => ` ${issue.path.join('.')}: ${issue.message}`, - ), - ].join('\n'); -} diff --git a/frontend/src/utils/validate/index.ts b/frontend/src/utils/validate/index.ts index ca40991cf..70f22c828 100644 --- a/frontend/src/utils/validate/index.ts +++ b/frontend/src/utils/validate/index.ts @@ -1,4 +1,3 @@ -export * from './error'; export * from './validateMetricsData'; export * from './validatePluginData'; export * from './validatePluginIndexData';