diff --git a/app/auth/authenticate.js b/app/auth/authenticate.js index e62d3776..35868ad7 100644 --- a/app/auth/authenticate.js +++ b/app/auth/authenticate.js @@ -1,11 +1,12 @@ +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' +import { defaultFieldResolver } from 'graphql' import jwt from 'jsonwebtoken' import jwksClient from 'jwks-rsa' -import { defaultFieldResolver } from 'graphql' -import { MapperKind, getDirective, mapSchema } from '@graphql-tools/utils' +import { FCP_REQUEST_AUTHENTICATION_001 } from '../logger/codes.js' -import { logger } from '../utils/logger.js' import { Unauthorized } from '../errors/graphql.js' import { AuthRole } from '../graphql/resolvers/authenticate.js' +import { logger } from '../logger/logger.js' export async function getJwtPublicKey (kid) { const client = jwksClient({ @@ -25,9 +26,11 @@ export async function getAuth (request, getJwtPublicKeyFunc = getJwtPublicKey) { const decodedToken = jwt.decode(token, { complete: true }) const header = decodedToken.header const signingKey = await getJwtPublicKeyFunc(header.kid) - return jwt.verify(token, signingKey) + const verified = jwt.verify(token, signingKey) + logger.health('#authenticate - JWT verified', { code: FCP_REQUEST_AUTHENTICATION_001 }) + return verified } catch (error) { - logger.error('#authenticate - Error verifying jwt', { error }) + logger.error('#authenticate - Error verifying jwt', { error, code: FCP_REQUEST_AUTHENTICATION_001 }) return {} } } diff --git a/app/data-sources/authenticate/AuthenticateDatabase.js b/app/data-sources/authenticate/AuthenticateDatabase.js index 8503ca1c..9a42cf76 100644 --- a/app/data-sources/authenticate/AuthenticateDatabase.js +++ b/app/data-sources/authenticate/AuthenticateDatabase.js @@ -1,5 +1,6 @@ -import { Sequelize, DataTypes } from 'sequelize' -import { logger } from '../../utils/logger.js' +import { DataTypes, Sequelize } from 'sequelize' +import { AUTHENTICATE_DATABASE_ALL_001 } from '../../logger/codes.js' +import { logger } from '../../logger/logger.js' const dbOptions = { database: process.env.AUTHENTICATE_DB_SCHEMA, @@ -34,22 +35,41 @@ export class AuthenticateDatabase { Updated: DataTypes.DATE }) + async healthCheck () { + try { + await this.dbRead.authenticate() + logger.health('#authenticate - database authenticated', { code: AUTHENTICATE_DATABASE_ALL_001 }) + } catch (error) { + logger.error('Authenticate database error', { error, code: AUTHENTICATE_DATABASE_ALL_001 }) + throw error + } + } + async getAuthenticateQuestionsAnswersByCRN (crn, employeeId) { - await this.dbAuditWrite.query(` + try { + logger.silly('Creating audit record in authenticate database', { employeeId, crn }) + await this.dbAuditWrite.query(` INSERT INTO Audits ([Date], [User], [Action], [Value]) VALUES(?, ?, ?, ?); `, { - replacements: [new Date().toISOString(), employeeId, 'Search', crn] - }) - - const answers = await this.Answer.findOne({ - attributes: ['CRN', 'Date', 'Event', 'Location', 'Updated'], - where: { - CRN: crn - } - }) - - logger.info(`getAuthenticateQuestionsAnswersByCRN: got answers for ${crn}`) - return answers + replacements: [new Date().toISOString(), employeeId, 'Search', crn] + }) + logger.debug('Created audit record in authenticate database', { employeeId, crn }) + + logger.silly('Getting authenticate questions answers by CRN', { crn, employeeId }) + const answers = await this.Answer.findOne({ + attributes: ['CRN', 'Date', 'Event', 'Location', 'Updated'], + where: { + CRN: crn + } + }) + logger.debug('Got authenticate questions answers by CRN', { crn, answers }) + + logger.health('#authenticate - answers retrieved', { code: AUTHENTICATE_DATABASE_ALL_001 }) + return answers + } catch (error) { + logger.error('Authenticate database error', { error, code: AUTHENTICATE_DATABASE_ALL_001 }) + throw error + } } } diff --git a/app/data-sources/entra-id/EntraIdApi.js b/app/data-sources/entra-id/EntraIdApi.js index fd4413ea..e16d8562 100644 --- a/app/data-sources/entra-id/EntraIdApi.js +++ b/app/data-sources/entra-id/EntraIdApi.js @@ -1,6 +1,7 @@ import { RESTDataSource } from '@apollo/datasource-rest' import { DefaultAzureCredential } from '@azure/identity' -import { logger } from '../../utils/logger.js' +import { ENTRA_REQUEST_EMPLOYEE_LOOKUP_001 } from '../../logger/codes.js' +import { logger } from '../../logger/logger.js' const credential = new DefaultAzureCredential() @@ -24,17 +25,19 @@ export class EntraIdApi extends RESTDataSource { } } ) - employeeId = response.employeeId - } catch (err) { - logger.error(err) + } catch (error) { + logger.error('Could not get the employee ID for the user', { entraIdUserObjectId, error, code: ENTRA_REQUEST_EMPLOYEE_LOOKUP_001 }) throw new Error(`Could not get the employee ID for the user: ${entraIdUserObjectId}`) } if (!employeeId) { + logger.error('Missing employee ID for user', { entraIdUserObjectId, code: ENTRA_REQUEST_EMPLOYEE_LOOKUP_001 }) throw new Error(`Missing employee ID for user: ${entraIdUserObjectId}`) } + logger.health('Successful get employee ID for user', { code: ENTRA_REQUEST_EMPLOYEE_LOOKUP_001 }) + return employeeId } } diff --git a/app/data-sources/rural-payments-portal/RuralPaymentsSession.js b/app/data-sources/rural-payments-portal/RuralPaymentsSession.js index 54027cfe..5e66b11d 100644 --- a/app/data-sources/rural-payments-portal/RuralPaymentsSession.js +++ b/app/data-sources/rural-payments-portal/RuralPaymentsSession.js @@ -8,7 +8,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent' import qs from 'qs' import { CookieJar } from 'tough-cookie' import { URL } from 'url' -import { logger } from '../../utils/logger.js' +import { logger } from '../../logger/logger.js' const defaultHeaders = { 'Accept-Encoding': 'gzip, deflate, br', @@ -26,7 +26,7 @@ const apiCredentials = { password: process.env.RURAL_PAYMENTS_PORTAL_PASSWORD } -logger.debug('#RuralPaymentsSession - Set api credentials', { apiCredentials }) +logger.verbose('#RuralPaymentsSession - Set api credentials', { apiCredentials }) export class RuralPaymentsSession extends RESTDataSource { baseURL = process.env.RURAL_PAYMENTS_PORTAL_API_URL @@ -85,7 +85,7 @@ export class RuralPaymentsSession extends RESTDataSource { async handleRedirects (response) { if ([301, 302, 303].includes(response?.status)) { const redirectUrl = new URL(response.headers.get('location')) - logger.debug('#RuralPaymentsSession - handle redirect', { + logger.verbose('#RuralPaymentsSession - handle redirect', { status: response?.status, redirect: redirectUrl.pathname }) @@ -107,7 +107,7 @@ export class RuralPaymentsSession extends RESTDataSource { } async fetch (path, incomingRequest) { - logger.debug('#RuralPaymentsSession - new request ', { + logger.verbose('#RuralPaymentsSession - new request ', { path, method: incomingRequest.method, cookies: this.jar @@ -116,7 +116,7 @@ export class RuralPaymentsSession extends RESTDataSource { .join(', ') }) const result = await super.fetch(path, incomingRequest) - logger.debug('#RuralPaymentsSession - response ', { + logger.verbose('#RuralPaymentsSession - response ', { path, status: result.response?.status, body: result.response.parsedBody, diff --git a/app/data-sources/rural-payments/RuralPayments.js b/app/data-sources/rural-payments/RuralPayments.js index 03c62353..96580c51 100644 --- a/app/data-sources/rural-payments/RuralPayments.js +++ b/app/data-sources/rural-payments/RuralPayments.js @@ -1,8 +1,10 @@ import { RESTDataSource } from '@apollo/datasource-rest' -import { StatusCodes } from 'http-status-codes' +import { GraphQLError } from 'graphql' +import StatusCodes from 'http-status-codes' import fetch from 'node-fetch' import qs from 'qs' -import { logger } from '../../utils/logger.js' +import { APIM_REQUEST_ACCESS_TOKEN_001, APIM_REQUEST_RURAL_PAYMENTS_REQUEST_001, RURALPAYMENTS_REQUEST_ALL_001 } from '../../logger/codes.js' +import { logger } from '../../logger/logger.js' const defaultHeaders = { 'Ocp-Apim-Subscription-Key': process.env.RP_INTERNAL_APIM_SUBSCRIPTION_KEY @@ -20,33 +22,56 @@ export class RuralPayments extends RESTDataSource { } async fetch (path, incomingRequest) { - logger.debug('#RuralPayments - new request', { + logger.verbose('#RuralPayments - new request', { path, method: incomingRequest.method }) const doRequest = async (count = 1) => { try { + const requestStart = Date.now() const result = await super.fetch(path, incomingRequest) - logger.debug('#RuralPayments - response ', { + const requestEnd = (Date.now() - requestStart) + logger.verbose('#RuralPayments - response ', { path, - status: result.response?.status, - body: result.response?.parsedBody + status: result.response?.status + }) + logger.health('#RuralPayments - request success', { + code: RURALPAYMENTS_REQUEST_ALL_001, + requestTimeMs: requestEnd + }) + logger.health('#APIM - request success', { + code: APIM_REQUEST_RURAL_PAYMENTS_REQUEST_001 }) return result } catch (error) { // Handle occasionally 500 error produced by APIM - // TODO: Once APIM has been fixed remove retry logic + // TODO: Once APIM has been fixed, remove retry logic if (error?.extensions?.response?.status === StatusCodes.INTERNAL_SERVER_ERROR && count < maximumRetries) { - logger.error(`#RuralPayments - Error, Retrying request (${count})`, { + logger.warn(`#RuralPayments - Error, Retrying request (${count})`, { error }) + return doRequest(count + 1) + } + // if response is text, then the error is from RoralPayments + if (error?.extensions?.response?.headers?.get('Content-Type')?.includes('text/html')) { + logger.error('#RuralPayments - error', { + error, path, method: incomingRequest.method, count, - error + response: error?.extensions?.response, + code: RURALPAYMENTS_REQUEST_ALL_001 + }) + } else { + // if response is json, then the error is from APIM + logger.error('#APIM - error', { + error, + path, + method: incomingRequest.method, + count, + response: error?.extensions?.response, + code: APIM_REQUEST_RURAL_PAYMENTS_REQUEST_001 }) - return doRequest(count + 1) } - logger.error('#RuralPayments - Error fetching data', { error }) throw error } } @@ -59,19 +84,17 @@ export class RuralPayments extends RESTDataSource { if (response.ok) { return } - logger.error('#RuralPayments - error', { - status: response?.status, - url: response?.url, - error: response?.error + + throw new GraphQLError(`${options.response.status}: ${options.response.statusText}`, { + extensions: options }) - throw await this.errorFromResponse(options) } async willSendRequest (_path, request) { if (!this.apimAccessToken) { - logger.debug('Getting APIM access token') + logger.verbose('Getting APIM access token') await this.getApimAccessToken() - logger.debug('APIM access token', { + logger.verbose('APIM access token', { apimAccessToken: this.apimAccessToken }) } @@ -105,7 +128,7 @@ export class RuralPayments extends RESTDataSource { } try { - logger.debug( + logger.verbose( `Token request url: ${process.env.RP_INTERNAL_APIM_ACCESS_TOKEN_URL}${process.env.RP_INTERNAL_APIM_TENANT_ID}/oauth2/v2.0/token` ) const response = await fetch( @@ -123,9 +146,10 @@ export class RuralPayments extends RESTDataSource { throw new Error('No access token returned') } + logger.health('Successfully got APIM access token', { code: APIM_REQUEST_ACCESS_TOKEN_001 }) this.apimAccessToken = data.access_token } catch (error) { - logger.error('Error getting APIM access token', { error }) + logger.error('Error getting APIM access token', { error, code: APIM_REQUEST_ACCESS_TOKEN_001 }) throw error } } diff --git a/app/data-sources/rural-payments/RuralPaymentsBusiness.js b/app/data-sources/rural-payments/RuralPaymentsBusiness.js index 81d8d16d..c3450982 100644 --- a/app/data-sources/rural-payments/RuralPaymentsBusiness.js +++ b/app/data-sources/rural-payments/RuralPaymentsBusiness.js @@ -1,13 +1,14 @@ -import { logger, sampleResponse } from '../../utils/logger.js' +import { logger } from '../../logger/logger.js' +import { sampleResponse } from '../../logger/utils.js' import { RuralPayments } from './RuralPayments.js' export class RuralPaymentsBusiness extends RuralPayments { async getOrganisationById (id) { - logger.debug('Getting organisation by ID', { id }) + logger.verbose('Getting organisation by ID', { id }) try { const organisationResponse = await this.get(`organisation/${id}`) - logger.debug('Organisation by ID', { organisationResponse }) + logger.verbose('Organisation by ID', { organisationResponse }) return organisationResponse._data } catch (error) { logger.error('Error getting organisation by ID', { id, error }) @@ -16,7 +17,7 @@ export class RuralPaymentsBusiness extends RuralPayments { } async getOrganisationBySBI (sbi) { - logger.debug('Getting organisation by SBI', { sbi }) + logger.verbose('Getting organisation by SBI', { sbi }) const body = JSON.stringify({ searchFieldType: 'SBI', primarySearchPhrase: sbi, diff --git a/app/data-sources/rural-payments/RuralPaymentsCustomer.js b/app/data-sources/rural-payments/RuralPaymentsCustomer.js index 20090346..ecf210aa 100644 --- a/app/data-sources/rural-payments/RuralPaymentsCustomer.js +++ b/app/data-sources/rural-payments/RuralPaymentsCustomer.js @@ -1,10 +1,11 @@ import { NotFound } from '../../errors/graphql.js' -import { logger, sampleResponse } from '../../utils/logger.js' +import { logger } from '../../logger/logger.js' +import { sampleResponse } from '../../logger/utils.js' import { RuralPayments } from './RuralPayments.js' export class RuralPaymentsCustomer extends RuralPayments { async getCustomerByCRN (crn) { - logger.debug('Getting customer by CRN', { crn }) + logger.verbose('Getting customer by CRN', { crn }) try { const body = JSON.stringify({ @@ -24,19 +25,22 @@ export class RuralPaymentsCustomer extends RuralPayments { logger.debug('Customer by CRN', { response: sampleResponse(response) }) if (!response?.id) { - logger.info(`Customer not found for CRN: ${crn}`) throw new NotFound('Customer not found') } return this.getPersonByPersonId(response.id) } catch (error) { - logger.error('Error getting customer by CRN', { crn, error }) + if (error instanceof NotFound) { + logger.warn('Customer not found for CRN', { crn, error }) + } else { + logger.error('Error getting customer by CRN', { crn, error }) + } throw error } } async getPersonByPersonId (personId) { - logger.debug('Getting person by person ID', { personId }) + logger.verbose('Getting person by person ID', { personId }) try { const response = await this.get(`person/${personId}/summary`) @@ -49,16 +53,16 @@ export class RuralPaymentsCustomer extends RuralPayments { } async getPersonBusinessesByPersonId (personId, sbi) { - logger.debug('Getting person businesses by person ID', { personId, sbi }) + logger.verbose('Getting person businesses by person ID', { personId, sbi }) try { const personBusinessSummaries = await this.get( // Currently requires and empty search parameter or it returns 500 error // page-size param set to ensure all orgs are retrieved - `organisation/person/${personId}/summary?search=&page-size=${process.env.VERSION_1_PAGE_SIZE}` + `organisation/person/${personId}/summary?search=&page-size=${process.env.VERSION_1_PAGE_SIZE || 100}` ) - logger.debug('Person businesses by person ID', { personBusinessSummaries }) + logger.debug('Person businesses by person ID', { response: sampleResponse(personBusinessSummaries) }) return personBusinessSummaries._data } catch (error) { logger.error('Error getting person businesses by person ID', { @@ -76,7 +80,7 @@ export class RuralPaymentsCustomer extends RuralPayments { page, size ) { - logger.debug('Getting notifications by organisation ID and person ID', { + logger.verbose('Getting notifications by organisation ID and person ID', { organisationId, personId, page, @@ -92,7 +96,7 @@ export class RuralPaymentsCustomer extends RuralPayments { size } }) - logger.debug('Notifications by organisation ID and person ID', { + logger.verbose('Notifications by organisation ID and person ID', { response }) diff --git a/app/errors/graphql.js b/app/errors/graphql.js index ab78dd26..03c684bd 100644 --- a/app/errors/graphql.js +++ b/app/errors/graphql.js @@ -12,5 +12,6 @@ export class NotFound extends GraphQLError { constructor (opts) { super(opts) this.extensions = { code: StatusCodes.NOT_FOUND, http: { status: StatusCodes.NOT_FOUND } } + this.code = StatusCodes.NOT_FOUND } } diff --git a/app/graphql/resolvers/business/business.js b/app/graphql/resolvers/business/business.js index 579422e1..5a555b12 100644 --- a/app/graphql/resolvers/business/business.js +++ b/app/graphql/resolvers/business/business.js @@ -1,12 +1,13 @@ import { NotFound } from '../../../errors/graphql.js' +import { logger } from '../../../logger/logger.js' +import { sampleResponse } from '../../../logger/utils.js' import { transformOrganisationCSApplicationToBusinessApplications } from '../../../transformers/rural-payments/applications-cs.js' import { transformOrganisationCPH } from '../../../transformers/rural-payments/business-cph.js' import { - transformBusinessCustomerPrivilegesToPermissionGroups, - transformOrganisationCustomer, - transformOrganisationCustomers + transformBusinessCustomerPrivilegesToPermissionGroups, + transformOrganisationCustomer, + transformOrganisationCustomers } from '../../../transformers/rural-payments/business.js' -import { logger, sampleResponse } from '../../../utils/logger.js' export const Business = { land ({ organisationId }) { diff --git a/app/graphql/resolvers/business/query.js b/app/graphql/resolvers/business/query.js index 22e32668..84711d77 100644 --- a/app/graphql/resolvers/business/query.js +++ b/app/graphql/resolvers/business/query.js @@ -1,6 +1,6 @@ -import { transformOrganisationToBusiness } from '../../../transformers/rural-payments/business.js' import { NotFound } from '../../../errors/graphql.js' -import { logger } from '../../../utils/logger.js' +import { logger } from '../../../logger/logger.js' +import { transformOrganisationToBusiness } from '../../../transformers/rural-payments/business.js' export const Query = { async business (__, { sbi }, { dataSources }) { diff --git a/app/graphql/resolvers/customer/customer.js b/app/graphql/resolvers/customer/customer.js index 963927af..27b576ae 100644 --- a/app/graphql/resolvers/customer/customer.js +++ b/app/graphql/resolvers/customer/customer.js @@ -1,19 +1,20 @@ +import { logger } from '../../../logger/logger.js' +import { sampleResponse } from '../../../logger/utils.js' import { transformAuthenticateQuestionsAnswers } from '../../../transformers/authenticate/question-answers.js' import { - ruralPaymentsPortalCustomerTransformer, - transformBusinessCustomerToCustomerPermissionGroups, - transformBusinessCustomerToCustomerRole, - transformNotificationsToMessages, - transformPersonSummaryToCustomerAuthorisedBusinesses, - transformPersonSummaryToCustomerAuthorisedFilteredBusiness + ruralPaymentsPortalCustomerTransformer, + transformBusinessCustomerToCustomerPermissionGroups, + transformBusinessCustomerToCustomerRole, + transformNotificationsToMessages, + transformPersonSummaryToCustomerAuthorisedBusinesses, + transformPersonSummaryToCustomerAuthorisedFilteredBusiness } from '../../../transformers/rural-payments/customer.js' -import { logger } from '../../../utils/logger.js' export const Customer = { async personId ({ crn }, __, { dataSources }) { const { id: personId } = await dataSources.ruralPaymentsCustomer.getCustomerByCRN(crn) - logger.debug('Get customer id from crn', { crn, personId }) + logger.verbose('Get customer id from crn', { crn, personId }) return personId }, @@ -34,7 +35,7 @@ export const Customer = { sbi ) - logger.verbose('Got customer business', { crn, sbi, personId, summary }) + logger.debug('Got customer business', { crn, personId, summary: sampleResponse(summary) }) return transformPersonSummaryToCustomerAuthorisedFilteredBusiness( { personId, crn, sbi }, summary @@ -48,7 +49,7 @@ export const Customer = { personId ) - logger.debug('Got customer businesses', { crn, personId, summary }) + logger.debug('Got customer businesses', { crn, personId, summary: sampleResponse(summary) }) return transformPersonSummaryToCustomerAuthorisedBusinesses( { personId, crn }, summary @@ -76,7 +77,7 @@ export const Customer = { export const CustomerBusiness = { async role ({ organisationId, crn }, __, { dataSources }) { - logger.debug('Get customer business role', { crn, organisationId }) + logger.verbose('Get customer business role', { crn, organisationId }) const businessCustomers = await dataSources.ruralPaymentsBusiness.getOrganisationCustomersByOrganisationId( organisationId diff --git a/app/index.js b/app/index.js index 20ad6df9..55e9f27e 100644 --- a/app/index.js +++ b/app/index.js @@ -2,8 +2,9 @@ import hapiApollo from '@as-integrations/hapi' import { context } from './graphql/context.js' import { apolloServer } from './graphql/server.js' +import { FCP_UNHANDLED_ERROR_001 } from './logger/codes.js' +import { logger } from './logger/logger.js' import { server } from './server.js' -import { logger } from './utils/logger.js' const init = async () => { await apolloServer.start() @@ -18,11 +19,16 @@ const init = async () => { }) await server.start() - logger.info(`Server running on ${server.info.uri}`) + logger.health(`Server running on ${server.info.uri}`, { code: FCP_UNHANDLED_ERROR_001 }) } -process.on('unhandledRejection', err => { - logger.error('#unhandledRejection', { err }) +process.on('unhandledRejection', error => { + logger.error('#unhandledRejection', { error, code: FCP_UNHANDLED_ERROR_001 }) + process.exit(1) +}) + +process.on('uncaughtException', error => { + logger.error('#uncaughtException', { error, code: FCP_UNHANDLED_ERROR_001 }) process.exit(1) }) diff --git a/app/insights.js b/app/insights.js index 0079b44f..9f127d62 100644 --- a/app/insights.js +++ b/app/insights.js @@ -1,5 +1,5 @@ import appInsights from 'applicationinsights' -import { logger } from './utils/logger.js' +import { logger } from './logger/logger.js' export function setupAppInsights () { if (process.env.APPINSIGHTS_CONNECTIONSTRING) { diff --git a/app/logger/codes.js b/app/logger/codes.js new file mode 100644 index 00000000..8defdb58 --- /dev/null +++ b/app/logger/codes.js @@ -0,0 +1,17 @@ +/* + * + * Log codes + * Format: ___ + */ + +export const FCP_REQUEST_AUTHENTICATION_001 = 'FCP_REQUEST_AUTHENTICATION_001' +export const FCP_UNHANDLED_ERROR_001 = 'FCP_UNHANDLED_ERROR_001' + +export const RURALPAYMENTS_REQUEST_ALL_001 = 'RURALPAYMENTS_REQUEST_ALL_001' + +export const APIM_REQUEST_ACCESS_TOKEN_001 = 'APIM_REQUEST_ACCESS_TOKEN_001' +export const APIM_REQUEST_RURAL_PAYMENTS_REQUEST_001 = 'APIM_REQUEST_RURAL_PAYMENTS_REQUEST_001' + +export const AUTHENTICATE_DATABASE_ALL_001 = 'AUTHENTICATE_DATABASE_ALL_001' + +export const ENTRA_REQUEST_EMPLOYEE_LOOKUP_001 = 'ENTRA_REQUEST_EMPLOYEE_LOOKUP_001' diff --git a/app/logger/logger.js b/app/logger/logger.js new file mode 100644 index 00000000..c030d9dc --- /dev/null +++ b/app/logger/logger.js @@ -0,0 +1,55 @@ +import { createLogger, format, transports } from 'winston' +import { AzureApplicationInsightsLogger } from 'winston-azure-application-insights' +import { jsonStringify } from './utils.js' + +import { stackTraceFormatter } from './winstonFormatters.js' + +const levels = { + levels: { + health: 1, + error: 2, + warn: 3, + info: 4, + verbose: 5, + debug: 6, + silly: 7 + }, + colors: { + health: 'lightblue', + error: 'red', + warn: 'yellow', + info: 'green', + verbose: 'cyan', + debug: 'blue', + silly: 'magenta' + } +} + +const transportTypes = [] +// If AppInsights is enabled, send logs to it +if (process.env.APPINSIGHTS_CONNECTIONSTRING) { + transportTypes.push( + new AzureApplicationInsightsLogger({ + key: process.env.APPINSIGHTS_CONNECTIONSTRING, + format: format.combine(stackTraceFormatter, format.json()) + }) + ) +} else { +// if AppInsights is not enabled, send logs to console + transportTypes.push(new transports.Console({ + format: format.combine( + format.align(), + format.colorize(), + stackTraceFormatter, + format.printf(info => { + return `${info.level}: ${info.message} ${jsonStringify(info)}` + }) + ) + })) +} + +export const logger = createLogger({ + levels: levels.levels, + level: process.env.LOG_LEVEL || 'info', + transports: transportTypes +}) diff --git a/app/logger/utils.js b/app/logger/utils.js new file mode 100644 index 00000000..e7529df5 --- /dev/null +++ b/app/logger/utils.js @@ -0,0 +1,70 @@ +/** + * Recursively convert an object to a JSON string, removing circular references, + * extracting useful information from Error objects, and removing Winston-specific + * properties. + */ +export const jsonStringify = (obj) => { + delete obj.level + delete obj.message + + // Log error instances as objects + if (obj.error) { + const error = { + message: obj.error.message, + stack: obj.error?.stack?.split('\n') + } + if (obj.error?.request) { + error.request = { + headers: obj.error?.request?.headers, + data: obj.error?.request?.data + } + } + if (obj.error?.response) { + error.response = { + status: obj.error?.response?.statusCode, + headers: obj.error?.response?.headers, + body: obj.error?.response?.body + } + } + obj.error = error + } + + // Prevent logging circular references + const cache = new Set() + return JSON.stringify( + obj, + (_, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.has(value)) { + return null + } + cache.add(value) + } + return value + }, + 4 + ) +} + +const sampleArray = array => { + const sampleSize = 5 + if (array.length <= sampleSize) { + return array + } + return array.slice(0, sampleSize) +} + +export const sampleResponse = response => { + if (Array.isArray(response)) { + return sampleArray(response) + } + + if (Array.isArray(response?._data)) { + return { + ...response, + _data: sampleArray(response._data) + } + } + + return response +} diff --git a/app/logger/winstonFormatters.js b/app/logger/winstonFormatters.js new file mode 100644 index 00000000..064c57f6 --- /dev/null +++ b/app/logger/winstonFormatters.js @@ -0,0 +1,29 @@ +import { format } from 'winston' + +const maxStackTrace = 20 + +Error.stackTraceFormatterLimit = Error.stackTraceFormatterLimit < maxStackTrace ? maxStackTrace : Error.stackTraceFormatterLimit + +export const stackTraceFormatter = format.printf((info) => { + if (!info?.stack && !info?.error?.stack) { + try { + throw new Error() + } catch (e) { + info.stack = e.stack + .split('\n') + .filter( + line => + !line.includes('node_modules') && + !line.includes('logger.js') && + !line.includes('winstonFormatters.js') && + !line.includes('node:') + ) + info.stack.shift() + if (!info?.stack?.length) { + delete info.stack + } + } + } + + return info +}) diff --git a/app/routes/healthy.js b/app/routes/healthy.js index eafbc11d..d92f11c5 100644 --- a/app/routes/healthy.js +++ b/app/routes/healthy.js @@ -1,7 +1,46 @@ +import { StatusCodes } from 'http-status-codes' +import { AuthenticateDatabase } from '../../app/data-sources/authenticate/AuthenticateDatabase.js' +import { EntraIdApi } from '../../app/data-sources/entra-id/EntraIdApi.js' +import { RuralPaymentsBusiness } from '../../app/data-sources/rural-payments/RuralPaymentsBusiness.js' + +const authenticateDatabaseHealthCheck = () => { + const authenticateDatabase = new AuthenticateDatabase() + return authenticateDatabase.healthCheck() +} + +const ruralPaymentsAPIMHealthCheck = () => { + const ruralPaymentsBusiness = new RuralPaymentsBusiness(null, { + headers: { + email: process.env.RURAL_PAYMENTS_PORTAL_EMAIL + } + }) + return ruralPaymentsBusiness.getOrganisationById(process.env.RP_INTERNAL_HEALTH_CHECK_ORGANISATION_ID) +} + +// TODO: implement health check for entra +const entraHealthCheck = () => { + const entraIdApi = new EntraIdApi() + return entraIdApi.getEmployeeId(process.env.ENTRA_HEALTH_CHECK_USER_OBJECT_ID) +} + +// TODO: implement health check for CRM +const crmHealthCheck = () => true + export const healthyRoute = { method: 'GET', path: '/healthy', - handler: (request, h) => { - return h.response('ok').code(200) + handler: async (_request, h) => { + try { + await Promise.all([ + authenticateDatabaseHealthCheck(), + ruralPaymentsAPIMHealthCheck(), + entraHealthCheck(), + crmHealthCheck() + ]) + + return h.response('ok').code(StatusCodes.OK) + } catch (error) { + return h.response('error').code(StatusCodes.INTERNAL_SERVER_ERROR) + } } } diff --git a/app/transformers/rural-payments/customer.js b/app/transformers/rural-payments/customer.js index 6df455a9..ed34744f 100644 --- a/app/transformers/rural-payments/customer.js +++ b/app/transformers/rural-payments/customer.js @@ -1,4 +1,5 @@ -import { logger } from '../../utils/logger.js' +import { logger } from '../../logger/logger.js' +import { sampleResponse } from '../../logger/utils.js' export function transformBusinessCustomerToCustomerRole (crn, customers) { const customer = customers.find( @@ -62,7 +63,7 @@ export function transformPersonSummaryToCustomerAuthorisedBusinesses ( logger.debug( 'Transforming person summary to customer authorised businesses', - { original: { properties, summary }, transformed } + { original: { properties, summary: sampleResponse(summary) }, transformed: sampleResponse(transformed) } ) return transformed } diff --git a/app/utils/logger.js b/app/utils/logger.js deleted file mode 100644 index 21561357..00000000 --- a/app/utils/logger.js +++ /dev/null @@ -1,110 +0,0 @@ -import winston from 'winston' -import { AzureApplicationInsightsLogger } from 'winston-azure-application-insights' - -Error.stackTraceLimit = Error.stackTraceLimit < 20 ? 20 : Error.stackTraceLimit - -function jsonStringifyRecursive (obj) { - delete obj.level - delete obj.message - - // Log error instances as objects - if (obj.error) { - const error = { - message: obj.error.message, - stack: obj.error.stack.split('\n') - } - if (obj.error?.request) { - error.request = { - headers: obj.error?.request?.headers, - data: obj.error?.request?.data - } - } - if (obj.error?.response) { - error.response = { - status: obj.error?.response?.statusCode, - headers: obj.error?.response?.headers, - body: obj.error?.response?.body - } - } - obj.error = error - } - - // Prevent logging circular references - const cache = new Set() - return JSON.stringify( - obj, - (_, value) => { - if (typeof value === 'object' && value !== null) { - if (cache.has(value)) return - cache.add(value) - } - return value - }, - 4 - ) -} - -const level = process.env.LOG_LEVEL || 'info' -const transports = [new winston.transports.Console()] -let format = winston.format.combine( - winston.format.align(), - winston.format.colorize(), - winston.format.printf(info => { - if (!info.stack && !info.error?.stack) { - try { - throw new Error() - } catch (e) { - info.stack = e.stack - .split('\n') - .filter( - line => - !line.includes('node_modules') && - !line.includes('logger.js') && - !line.includes('node:internal') - ) - info.stack.shift() - } - } - return `${info.level}: ${info.message} ${jsonStringifyRecursive(info)}` - }) -) - -if (process.env.APPINSIGHTS_CONNECTIONSTRING) { - format = winston.format.json() - transports.push( - new AzureApplicationInsightsLogger({ - key: process.env.APPINSIGHTS_CONNECTIONSTRING - }) - ) -} else { - transports.push(new winston.transports.Console()) -} - -export const logger = winston.createLogger({ - level, - transports, - format -}) - -const sampleArray = array => { - const sampleSize = 5 - if (array.length <= sampleSize) { - return array - } - return array.slice(0, sampleSize) -} - -export const sampleResponse = response => { - if (Array.isArray(response)) { - return sampleArray(response) - } - - if (Array.isArray(response?._data)) { - return { - ...response, - _data: sampleArray(response._data) - } - } - - return response -} diff --git a/sonar-project.properties b/sonar-project.properties index 1d62d515..7de8fedf 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,3 @@ -sonar.javascript.exclusions=**/jest.config.js,**/__mocks__/**,**/node_modules/**,**/test/**,**/test-output/**,jest.init.js,app/index.js,app/insights.js,app/data-sources/rural-payments-portal/RuralPaymentsSession.js,app/graphql/server.js,app/graphql/schema.js,app/utils/logger.js +sonar.javascript.exclusions=**/jest.config.js,**/__mocks__/**,**/node_modules/**,**/test/**,**/test-output/**,jest.init.js,app/index.js,app/insights.js,app/data-sources/rural-payments-portal/RuralPaymentsSession.js,app/graphql/server.js,app/graphql/schema.js,app/logger/** sonar.javascript.lcov.reportPaths=test-output/lcov.info sonar.exclusions=/test/**,**/*.test.js,*snyk_report.html,*snyk_report.css,/mocks/** diff --git a/test/integration/narrow/routes/healthy.test.js b/test/integration/narrow/routes/healthy.test.js index d765a38d..25c6e628 100644 --- a/test/integration/narrow/routes/healthy.test.js +++ b/test/integration/narrow/routes/healthy.test.js @@ -12,7 +12,7 @@ describe('Healthy test', () => { } const response = await server.inject(options) - expect(response.statusCode).toEqual(200) + expect(response.statusCode).toEqual(500) }) afterEach(async () => {