Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ updates:

forms:
patterns:
- '@defra/forms-common'
- '@defra/forms-model'

aws:
Expand Down
1 change: 1 addition & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ module.exports = {
// See: https://jestjs.io/docs/ecmascript-modules
transformIgnorePatterns: [
`node_modules/(?!${[
'@defra/forms-common', // Supports ESM only
'@defra/forms-model/.*',
'@joi/date', // Supports ESM only
'nanoid', // Supports ESM only
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@aws-sdk/client-s3": "^3.1062.0",
"@aws-sdk/client-sns": "^3.1062.0",
"@aws-sdk/credential-providers": "3.1073.0",
"@defra/forms-common": "^0.1.0",
"@defra/forms-model": "^4.0.0",
"@defra/hapi-tracing": "^1.30.0",
"@elastic/ecs-pino-format": "^1.5.0",
Expand Down
12 changes: 4 additions & 8 deletions src/helpers/logging/logger-options.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getTraceId } from '@defra/hapi-tracing'
import { addLogContextToMessage, logContextMixin } from '@defra/forms-common'
import { ecsFormat } from '@elastic/ecs-pino-format'

import { config } from '~/src/config/index.js'
Expand Down Expand Up @@ -28,13 +28,9 @@ export const loggerOptions = /** @type {any} */ ({
level: logConfig.level,
...formatters[/** @type {'ecs' | 'pino-pretty'} */ (logConfig.format)],
nesting: true,
mixin() {
const mixinValues = {}
const traceId = getTraceId()
if (traceId) {
mixinValues.trace = { id: traceId }
}
return mixinValues
mixin: logContextMixin,
hooks: {
logMethod: addLogContextToMessage
}
})

Expand Down
13 changes: 13 additions & 0 deletions src/helpers/logging/logger-options.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { addLogContextToMessage, logContextMixin } from '@defra/forms-common'

import { loggerOptions } from '~/src/helpers/logging/logger-options.js'

describe('logger-options', () => {
it('should use the forms-common log context mixin', () => {
expect(loggerOptions.mixin).toBe(logContextMixin)
})

it('should use the forms-common log message hook', () => {
expect(loggerOptions.hooks.logMethod).toBe(addLogContextToMessage)
})
})
14 changes: 1 addition & 13 deletions src/helpers/logging/request-logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,7 @@ export const requestLogger = {
log4xxResponseErrors: true,
logRequestComplete: true,
customRequestCompleteMessage(request, responseTime) {
const { credentials, isAuthenticated } = request.auth

let userPrefix = ''

if (isAuthenticated && credentials.user) {
const { user } = credentials

if (user.oid) {
userPrefix = ` [${user.oid}] `
}
}

return `[response]${userPrefix} ${request.method} ${request.path} ${request.raw.res.statusCode} (${responseTime}ms)`
return `[response] ${request.method} ${request.path} ${request.raw.res.statusCode} (${responseTime}ms)`
}
}
}
Expand Down
49 changes: 47 additions & 2 deletions src/helpers/request-tracing.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
import {
applyTraceHeaders as applyTraceHeader,
requestTracing as requestTracingPlugin
} from '@defra/forms-common'
import { tracing } from '@defra/hapi-tracing'

import { config } from '~/src/config/index.js'

const tracingHeader = config.get('tracing.header')

/**
* Adds the correlation ID of the current log context to the headers of an
* outbound HTTP request, so the receiving service logs the same ID
* @template {Record<string, any>} Headers
* @param {Headers} [headers] - the existing headers
* @returns {Headers | undefined}
*/
export function applyTraceHeaders(headers) {
return applyTraceHeader(headers, tracingHeader)
}

/**
* Gets the unique ID (`oid`) of the authenticated user
* @param {Request} request
* @returns {string | undefined}
*/
export function getRequestUserId(request) {
const { credentials, isAuthenticated } = request.auth

if (!isAuthenticated) {
return undefined
}

return credentials.user?.oid
}

/**
* Starts a log context for every request, holding the correlation ID from the
* tracing header (or a new ID when the caller sent none) and the ID of the
* authenticated user. The logger writes both on every log line.
* @satisfies {ServerRegisterPluginObject<RequestTracingOptions>}
*/
export const requestTracing = {
plugin: tracing,
plugin: requestTracingPlugin,
options: {
tracingHeader: config.get('tracing.header')
tracingHeader,
tracingPlugin: tracing.plugin,
getUserId: getRequestUserId
}
}

/**
* @import { RequestTracingOptions } from '@defra/forms-common'
* @import { Request, ServerRegisterPluginObject } from '@hapi/hapi'
*/
165 changes: 165 additions & 0 deletions src/helpers/request-tracing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
createLogContext,
getCorrelationId,
getUserId,
runWithLogContext
} from '@defra/forms-common'
import { getTraceId } from '@defra/hapi-tracing'
import hapi from '@hapi/hapi'

import {
applyTraceHeaders,
getRequestUserId,
requestTracing
} from '~/src/helpers/request-tracing.js'

describe('request-tracing', () => {
const tracingHeader = 'x-cdp-request-id'
const correlationId = '1066e8cc-8e1e-4671-8ad7-b4cd9c95bb94'
const userId = '86758ba9-92e7-4287-9751-7705e449f0a5'

describe('plugin', () => {
/** @type {Server} */
let server

/** @type {{ correlationId?: string, userId?: string }} */
let responseContext

beforeEach(async () => {
server = hapi.server()
responseContext = {}

server.auth.scheme('test', () => ({
authenticate(request, h) {
return h.authenticated({
credentials: /** @type {AuthCredentials} */ (
/** @type {unknown} */ ({ user: { oid: userId } })
)
})
}
}))
server.auth.strategy('test', 'test')

await server.register(requestTracing)

const handler = () => ({
correlationId: getCorrelationId(),
traceId: getTraceId(),
userId: getUserId()
})

server.route([
{ method: 'GET', path: '/open', handler },
{
method: 'GET',
path: '/secure',
handler,
options: { auth: 'test' }
}
])

// The response log is written when this event is emitted
server.events.on('response', () => {
responseContext = {
correlationId: getCorrelationId(),
userId: getUserId()
}
})
})

afterEach(async () => {
await server.stop()
})

it('should use the correlation ID from the tracing header', async () => {
const { result } = await server.inject({
method: 'GET',
url: '/open',
headers: { [tracingHeader]: correlationId }
})

expect(result).toEqual({
correlationId,
traceId: correlationId,
userId: undefined
})
expect(responseContext).toEqual({ correlationId, userId: undefined })
})

it('should add the ID of the authenticated user', async () => {
const { result } = await server.inject({
method: 'GET',
url: '/secure',
headers: { [tracingHeader]: correlationId }
})

expect(result).toEqual({
correlationId,
traceId: correlationId,
userId
})
expect(responseContext).toEqual({ correlationId, userId })
})
})

describe('getRequestUserId', () => {
/**
* @param {object} auth
*/
function buildRequest(auth) {
return /** @type {Request} */ (/** @type {unknown} */ ({ auth }))
}

it('should return the oid of the user', () => {
const request = buildRequest({
isAuthenticated: true,
credentials: { user: { oid: userId } }
})

expect(getRequestUserId(request)).toBe(userId)
})

it('should return undefined when the user has no oid', () => {
const request = buildRequest({
isAuthenticated: true,
credentials: { user: {} }
})

expect(getRequestUserId(request)).toBeUndefined()
})

it('should return undefined when the request is not authenticated', () => {
const request = buildRequest({
isAuthenticated: false,
credentials: { user: { oid: userId } }
})

expect(getRequestUserId(request)).toBeUndefined()
})
})

describe('applyTraceHeaders', () => {
it('should return the headers unchanged outside of a log context', () => {
const headers = { accept: 'application/json' }

expect(applyTraceHeaders(headers)).toBe(headers)
expect(applyTraceHeaders(undefined)).toBeUndefined()
})

it('should add the tracing header', () => {
runWithLogContext(createLogContext({ correlationId }), () => {
expect(applyTraceHeaders({ accept: 'application/json' })).toEqual({
accept: 'application/json',
[tracingHeader]: correlationId
})
expect(applyTraceHeaders(undefined)).toEqual({
[tracingHeader]: correlationId
})
})
})
})
})

/**
* @import { AuthCredentials, Request, Server } from '@hapi/hapi'
*/
10 changes: 10 additions & 0 deletions src/lib/fetch.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Boom from '@hapi/boom'
import Wreck from '@hapi/wreck'

import { applyTraceHeaders } from '~/src/helpers/request-tracing.js'

const MIN_OK_STATUS = 200
const MAX_OK_STATUS = 299

Expand All @@ -12,6 +14,14 @@ const MAX_OK_STATUS = 299
* @returns {Promise<{response: object, body: any}>}
*/
export async function request(method, url, options) {
const headers = applyTraceHeaders(
/** @type {{ headers?: Record<string, string> }} */ (options).headers
)

if (headers) {
options = { ...options, headers }
}

const response = await Wreck.request(method, url.href, options)
const body = await Wreck.read(response, options)

Expand Down
26 changes: 26 additions & 0 deletions src/lib/fetch.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createLogContext, runWithLogContext } from '@defra/forms-common'
import Boom from '@hapi/boom'
import Wreck from '@hapi/wreck'

Expand Down Expand Up @@ -43,6 +44,31 @@ describe('fetch utilities', () => {
expect(jest.mocked(Wreck.read)).toHaveBeenCalledWith(mockResponse, {})
})

it('should send the correlation ID of the log context', async () => {
/** @type {any} */
const mockResponse = { statusCode: 200, headers: {} }
const context = createLogContext({ correlationId: 'correlation-id' })

jest.mocked(Wreck.request).mockResolvedValue(mockResponse)
jest.mocked(Wreck.read).mockResolvedValue({})

const url = new URL('http://example.com/api')
await runWithLogContext(context, () =>
request('get', url, { headers: { accept: 'application/json' } })
)

expect(jest.mocked(Wreck.request)).toHaveBeenCalledWith(
'get',
'http://example.com/api',
{
headers: {
accept: 'application/json',
'x-cdp-request-id': 'correlation-id'
}
}
)
})

it('should throw Boom error for non-200 status with message in body', async () => {
/** @type {any} */
const mockResponse = { statusCode: 404 }
Expand Down
Loading
Loading