Skip to content

Commit 7fadaed

Browse files
committed
fix: log connections that timeout, abort, or send unparable data
1 parent 90936a8 commit 7fadaed

3 files changed

Lines changed: 204 additions & 1 deletion

File tree

src/http/plugins/log-request.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { PartialHttpData, parsePartialHttp } from '@internal/http'
12
import { logger, logSchema, redactQueryParamFromRequest } from '@internal/monitoring'
3+
import { FastifyInstance } from 'fastify'
24
import { FastifyReply } from 'fastify/types/reply'
35
import { FastifyRequest } from 'fastify/types/request'
46
import fastifyPlugin from 'fastify-plugin'
7+
import { Socket } from 'net'
8+
import { getConfig } from '../../config'
59

610
interface 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
*/
3036
export 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) =>
102193
interface 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+
}

src/internal/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './agent'
2+
export * from './partial-http-parser'
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { getConfig } from '../../config'
2+
3+
const { isMultitenant, requestXForwardedHostRegExp } = getConfig()
4+
5+
const REQUEST_LINE_REGEX = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/[\d.]+)?$/i
6+
const LINE_SPLIT_REGEX = /\r?\n/
7+
// Validate header name (RFC 7230 token characters)
8+
const HEADER_NAME_REGEX = /^[a-z0-9!#$%&'*+\-.^_`|~]+$/
9+
10+
const MAX_HEADER_LINES = 100
11+
12+
export interface PartialHttpData {
13+
method: string
14+
url: string
15+
headers: Record<string, string>
16+
tenantId: string
17+
length: number
18+
}
19+
20+
/**
21+
* Parses partial HTTP request data from raw buffers.
22+
* Returns defaults if parsing fails.
23+
*/
24+
export function parsePartialHttp(dataChunks: Buffer[]): PartialHttpData {
25+
const result: PartialHttpData = {
26+
method: 'UNKNOWN',
27+
url: '/',
28+
headers: {},
29+
tenantId: isMultitenant ? 'unknown' : 'storage-single-tenant',
30+
length: 0,
31+
}
32+
33+
if (dataChunks.length === 0) {
34+
return result
35+
}
36+
37+
try {
38+
const partialData = Buffer.concat(dataChunks).toString('utf8')
39+
const lines = partialData.split(LINE_SPLIT_REGEX)
40+
result.length = partialData.length
41+
42+
// Parse request line: "METHOD /path HTTP/version"
43+
if (lines[0]) {
44+
const requestLine = lines[0].match(REQUEST_LINE_REGEX)
45+
if (requestLine) {
46+
result.method = requestLine[1].toUpperCase()
47+
result.url = requestLine[2]
48+
}
49+
}
50+
51+
// Parse headers (skip line 0, limit total lines)
52+
const headerLineLimit = Math.min(lines.length, MAX_HEADER_LINES + 1)
53+
for (let i = 1; i < headerLineLimit; i++) {
54+
const line = lines[i]
55+
if (!line || line.trim() === '') continue
56+
57+
const colonIndex = line.indexOf(':')
58+
if (colonIndex > 0) {
59+
const field = line.substring(0, colonIndex).trim().toLowerCase()
60+
const value = line.substring(colonIndex + 1).trim()
61+
if (HEADER_NAME_REGEX.test(field)) {
62+
result.headers[field] = value
63+
}
64+
}
65+
}
66+
67+
// Extract tenantId from x-forwarded-host if multitenant
68+
if (isMultitenant && requestXForwardedHostRegExp && result.headers['x-forwarded-host']) {
69+
const match = result.headers['x-forwarded-host'].match(requestXForwardedHostRegExp)
70+
if (match && match[1]) {
71+
result.tenantId = match[1]
72+
}
73+
}
74+
} catch {
75+
// Parsing failed - return defaults
76+
// This catches malformed UTF-8, regex errors, etc.
77+
}
78+
79+
return result
80+
}

0 commit comments

Comments
 (0)