Skip to content
Open
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
102 changes: 101 additions & 1 deletion src/http/plugins/log-request.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { logger, logSchema, redactQueryParamFromRequest } from '@internal/monitoring'
import { FastifyInstance } from 'fastify'
import { FastifyReply } from 'fastify/types/reply'
import { FastifyRequest } from 'fastify/types/request'
import fastifyPlugin from 'fastify-plugin'
import { Socket } from 'net'
import { getConfig } from '../../config'

interface RequestLoggerOptions {
excludeUrls?: string[]
Expand All @@ -23,13 +26,79 @@ declare module 'fastify' {
}
}

const { version } = getConfig()

/**
* Request logger plugin
* @param options
*/
export const logRequest = (options: RequestLoggerOptions) =>
fastifyPlugin(
async (fastify) => {
// Used to track cleanup functions per socket
const socketCleanupMap = new WeakMap<Socket, () => void>()
const cleanupSocketListeners = (socket: Socket) => {
const cleanup = socketCleanupMap.get(socket)
if (cleanup) {
socketCleanupMap.delete(socket)
cleanup()
}
}

// Watch for connections that timeout or disconnect before complete HTTP headers are received
// For keep-alive connections, track each potential request independently
const onConnection = (socket: Socket) => {
const connectionStart = Date.now()
let currentRequestStart = connectionStart
let hasReceivedData = false
let requestLogged = false

// Store cleanup function so hooks can mark requests as logged
socketCleanupMap.set(socket, () => {
requestLogged = true
})

// Track when data arrives for a potential request
const onData = () => {
// Reset tracking for each new request on keep-alive connections
if (!hasReceivedData || requestLogged) {
hasReceivedData = true
currentRequestStart = Date.now()
requestLogged = false
}
}
socket.on('data', onData)
Comment on lines +56 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup is registered once and it's removed after first request but keep-alive on the same connection can revert requestLogged then new request wouldn't have the callback and would do extra log


// Remove data listener on socket error to prevent listener leak
socket.once('error', () => {
socket.removeListener('data', onData)
})

socket.once('close', () => {
socket.removeListener('data', onData)
socketCleanupMap.delete(socket)

// Log if connection closed without a logged request
// This covers: idle timeouts, partial data, malformed requests
if (!requestLogged) {
const req = createPartialLogRequest(fastify, socket, currentRequestStart)

doRequestLog(req, {
excludeUrls: options.excludeUrls,
statusCode: 'ABORTED CONN',
responseTime: (Date.now() - req.startTime) / 1000,
})
}
})
}

fastify.server.on('connection', onConnection)

// Clean up on close
fastify.addHook('onClose', async () => {
fastify.server.removeListener('connection', onConnection)
})

fastify.addHook('onRequest', async (req, res) => {
req.startTime = Date.now()

Expand All @@ -40,6 +109,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
statusCode: 'ABORTED REQ',
responseTime: (Date.now() - req.startTime) / 1000,
})
cleanupSocketListeners(req.raw.socket)
return
}

Expand All @@ -49,6 +119,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
statusCode: 'ABORTED RES',
responseTime: (Date.now() - req.startTime) / 1000,
})
cleanupSocketListeners(req.raw.socket)
}
})
})
Expand Down Expand Up @@ -94,6 +165,9 @@ export const logRequest = (options: RequestLoggerOptions) =>
responseTime: reply.elapsedTime,
executionTime: req.executionTime,
})

// Mark request as logged so socket close handler doesn't log it again
cleanupSocketListeners(req.raw.socket)
})
},
{ name: 'log-request' }
Expand All @@ -102,7 +176,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
interface LogRequestOptions {
reply?: FastifyReply
excludeUrls?: string[]
statusCode: number | 'ABORTED REQ' | 'ABORTED RES'
statusCode: number | 'ABORTED REQ' | 'ABORTED RES' | 'ABORTED CONN'
responseTime: number
executionTime?: number
}
Expand Down Expand Up @@ -179,3 +253,29 @@ function getFirstDefined<T>(...values: any[]): T | undefined {
}
return undefined
}

/**
* Creates a minimal FastifyRequest for logging aborted connections.
* Used when connection closes before a complete HTTP request is received.
*/
export function createPartialLogRequest(
fastify: FastifyInstance,
socket: Socket,
startTime: number
) {
return {
method: 'UNKNOWN',
headers: {},
url: '/',
ip: socket.remoteAddress || 'unknown',
id: 'no-request',
log: fastify.log.child({
reqId: 'no-request',
appVersion: version,
}),
startTime,
raw: {},
routeOptions: { config: {} },
resources: [],
} as unknown as FastifyRequest
}