1+ import { PartialHttpData , parsePartialHttp } from '@internal/http'
12import { logger , logSchema , redactQueryParamFromRequest } from '@internal/monitoring'
3+ import { FastifyInstance } from 'fastify'
24import { FastifyReply } from 'fastify/types/reply'
35import { FastifyRequest } from 'fastify/types/request'
46import fastifyPlugin from 'fastify-plugin'
7+ import { Socket } from 'net'
8+ import { getConfig } from '../../config'
59
610interface RequestLoggerOptions {
711 excludeUrls ?: string [ ]
@@ -23,13 +27,95 @@ declare module 'fastify' {
2327 }
2428}
2529
30+ const { version } = getConfig ( )
31+
2632/**
2733 * Request logger plugin
2834 * @param options
2935 */
3036export const logRequest = ( options : RequestLoggerOptions ) =>
3137 fastifyPlugin (
3238 async ( fastify ) => {
39+ // Used to track cleanup functions per socket
40+ const socketCleanupMap = new WeakMap < Socket , ( ) => void > ( )
41+ const cleanupSocketListeners = ( socket : Socket ) => {
42+ const cleanup = socketCleanupMap . get ( socket )
43+ if ( cleanup ) {
44+ socketCleanupMap . delete ( socket )
45+ cleanup ( )
46+ }
47+ }
48+
49+ // Watch for connections that timeout or disconnect before complete HTTP headers are received
50+ // For keep-alive connections, track each potential request independently
51+ const onConnection = ( socket : Socket ) => {
52+ const captureByteLimit = 2048
53+ let currentRequestData : Buffer [ ] = [ ]
54+ let currentRequestDataSize = 0
55+ let currentRequestStart = Date . now ( )
56+ let waitingForRequest = false
57+ let pendingRequestLogged = false
58+
59+ // Store cleanup function so hooks can access it
60+ socketCleanupMap . set ( socket , ( ) => {
61+ pendingRequestLogged = true
62+ waitingForRequest = false
63+ currentRequestData = [ ]
64+ currentRequestDataSize = 0
65+ } )
66+
67+ // Capture partial data sent before connection closes
68+ const onData = ( chunk : Buffer ) => {
69+ // Start tracking a new potential request when we receive data after a completed one
70+ if ( ! waitingForRequest ) {
71+ waitingForRequest = true
72+ currentRequestData = [ ]
73+ currentRequestDataSize = 0
74+ currentRequestStart = Date . now ( )
75+ pendingRequestLogged = false
76+ }
77+
78+ const remaining = captureByteLimit - currentRequestDataSize
79+ if ( remaining > 0 ) {
80+ const slicedChunk = chunk . subarray ( 0 , Math . min ( chunk . length , remaining ) )
81+ currentRequestData . push ( slicedChunk )
82+ currentRequestDataSize += slicedChunk . length
83+ }
84+ }
85+ socket . on ( 'data' , onData )
86+
87+ // Remove data listener on socket error to prevent listener leak
88+ socket . once ( 'error' , ( ) => {
89+ socket . removeListener ( 'data' , onData )
90+ } )
91+
92+ socket . once ( 'close' , ( ) => {
93+ socket . removeListener ( 'data' , onData )
94+ socketCleanupMap . delete ( socket )
95+
96+ // Only log if we were waiting for a request that was never properly logged
97+ if ( ! waitingForRequest || currentRequestData . length === 0 || pendingRequestLogged ) {
98+ return
99+ }
100+
101+ const parsedHttp = parsePartialHttp ( currentRequestData )
102+ const req = createPartialLogRequest ( fastify , socket , parsedHttp , currentRequestStart )
103+
104+ doRequestLog ( req , {
105+ excludeUrls : options . excludeUrls ,
106+ statusCode : 'ABORTED CONN' ,
107+ responseTime : ( Date . now ( ) - req . startTime ) / 1000 ,
108+ } )
109+ } )
110+ }
111+
112+ fastify . server . on ( 'connection' , onConnection )
113+
114+ // Clean up on close
115+ fastify . addHook ( 'onClose' , async ( ) => {
116+ fastify . server . removeListener ( 'connection' , onConnection )
117+ } )
118+
33119 fastify . addHook ( 'onRequest' , async ( req , res ) => {
34120 req . startTime = Date . now ( )
35121
@@ -40,6 +126,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
40126 statusCode : 'ABORTED REQ' ,
41127 responseTime : ( Date . now ( ) - req . startTime ) / 1000 ,
42128 } )
129+ cleanupSocketListeners ( req . raw . socket )
43130 return
44131 }
45132
@@ -49,6 +136,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
49136 statusCode : 'ABORTED RES' ,
50137 responseTime : ( Date . now ( ) - req . startTime ) / 1000 ,
51138 } )
139+ cleanupSocketListeners ( req . raw . socket )
52140 }
53141 } )
54142 } )
@@ -94,6 +182,9 @@ export const logRequest = (options: RequestLoggerOptions) =>
94182 responseTime : reply . elapsedTime ,
95183 executionTime : req . executionTime ,
96184 } )
185+
186+ // Mark request as logged so socket close handler doesn't log it again
187+ cleanupSocketListeners ( req . raw . socket )
97188 } )
98189 } ,
99190 { name : 'log-request' }
@@ -102,7 +193,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
102193interface LogRequestOptions {
103194 reply ?: FastifyReply
104195 excludeUrls ?: string [ ]
105- statusCode : number | 'ABORTED REQ' | 'ABORTED RES'
196+ statusCode : number | 'ABORTED REQ' | 'ABORTED RES' | 'ABORTED CONN'
106197 responseTime : number
107198 executionTime ?: number
108199}
@@ -179,3 +270,34 @@ function getFirstDefined<T>(...values: any[]): T | undefined {
179270 }
180271 return undefined
181272}
273+
274+ /**
275+ * Creates a minimal FastifyRequest from partial HTTP data.
276+ * Used for consistent logging when request parsing fails.
277+ */
278+ export function createPartialLogRequest (
279+ fastify : FastifyInstance ,
280+ socket : Socket ,
281+ httpData : PartialHttpData ,
282+ startTime : number
283+ ) {
284+ return {
285+ method : httpData . method ,
286+ url : httpData . url ,
287+ headers : httpData . headers ,
288+ ip : socket . remoteAddress || 'unknown' ,
289+ id : 'no-request' ,
290+ log : fastify . log . child ( {
291+ tenantId : httpData . tenantId ,
292+ project : httpData . tenantId ,
293+ reqId : 'no-request' ,
294+ appVersion : version ,
295+ dataLength : httpData . length ,
296+ } ) ,
297+ startTime,
298+ tenantId : httpData . tenantId ,
299+ raw : { } ,
300+ routeOptions : { config : { } } ,
301+ resources : [ ] ,
302+ } as unknown as FastifyRequest
303+ }
0 commit comments