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

interface RequestLoggerOptions {
excludeUrls?: string[]
Expand All @@ -18,17 +23,69 @@

interface FastifyContextConfig {
operation?: { type: string }
resources?: (req: FastifyRequest<any>) => string[]

Check warning on line 26 in src/http/plugins/log-request.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-24.04 / Node 20

Unexpected any. Specify a different type
}
}

const { version } = getConfig()

/**
* Request logger plugin
* @param options
*/
export const logRequest = (options: RequestLoggerOptions) =>
fastifyPlugin(
async (fastify) => {
// Watch for connections that timeout or disconnect before complete HTTP headers are received
// Log if socket closes before request is triggered
fastify.server.on('connection', (socket) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

we would need to remove these event handlers at the end of the request otherwise we will create memory leaks

Copy link
Contributor

Choose a reason for hiding this comment

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

Also we need to consider that we use keep-alive so connections might be reused hence the listeners would be removed in the current implementation

let hasRequest = false
const startTime = Date.now()
const partialData: Buffer[] = []
// Only store 2kb per request
const captureByteLimit = 2048

// Capture partial data sent before connection closes
const onData = (chunk: Buffer) => {
const remaining = captureByteLimit - partialData.length
if (remaining > 0) {
partialData.push(chunk.subarray(0, Math.min(chunk.length, remaining)))
}

if (partialData.length >= captureByteLimit) {
socket.removeListener('data', onData)
}
}
socket.on('data', onData)

// Track if this socket ever receives an HTTP request
const onRequest = (req: IncomingMessage) => {
if (req.socket === socket) {
hasRequest = true
socket.removeListener('data', onData)
fastify.server.removeListener('request', onRequest)
}
}
fastify.server.on('request', onRequest)

socket.once('close', () => {
socket.removeListener('data', onData)
fastify.server.removeListener('request', onRequest)
if (hasRequest) {
return
}

const parsedHttp = parsePartialHttp(partialData)
const req = createPartialLogRequest(fastify, socket, parsedHttp, startTime)

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

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

Expand Down Expand Up @@ -60,7 +117,7 @@
const resources = getFirstDefined<string[]>(
req.resources,
req.routeOptions.config.resources?.(req),
(req.raw as any).resources,

Check warning on line 120 in src/http/plugins/log-request.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-24.04 / Node 20

Unexpected any. Specify a different type
resourceFromParams ? [resourceFromParams] : ([] as string[])
)

Expand Down Expand Up @@ -95,7 +152,7 @@
interface LogRequestOptions {
reply?: FastifyReply
excludeUrls?: string[]
statusCode: number | 'ABORTED REQ' | 'ABORTED RES'
statusCode: number | 'ABORTED REQ' | 'ABORTED RES' | 'ABORTED CONN'
responseTime: number
}

Expand All @@ -115,7 +172,7 @@
const rId = req.id
const cIP = req.ip
const statusCode = options.statusCode
const error = (req.raw as any).executionError || req.executionError

Check warning on line 175 in src/http/plugins/log-request.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-24.04 / Node 20

Unexpected any. Specify a different type
const tenantId = req.tenantId

const buildLogMessage = `${tenantId} | ${rMeth} | ${statusCode} | ${cIP} | ${rId} | ${rUrl} | ${uAgent}`
Expand All @@ -134,7 +191,7 @@
})
}

function getFirstDefined<T>(...values: any[]): T | undefined {

Check warning on line 194 in src/http/plugins/log-request.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-24.04 / Node 20

Unexpected any. Specify a different type
for (const value of values) {
if (value !== undefined) {
return value
Expand All @@ -142,3 +199,34 @@
}
return undefined
}

/**
* Creates a minimal FastifyRequest from partial HTTP data.
* Used for consistent logging when request parsing fails.
*/
export function createPartialLogRequest(
fastify: FastifyInstance,
socket: Socket,
httpData: PartialHttpData,
startTime: number
) {
return {
method: httpData.method,
url: httpData.url,
headers: httpData.headers,
ip: socket.remoteAddress || 'unknown',
id: 'no-request',
log: fastify.log.child({
tenantId: httpData.tenantId,
project: httpData.tenantId,
reqId: 'no-request',
appVersion: version,
dataLength: httpData.length,
}),
startTime,
tenantId: httpData.tenantId,
raw: {},
routeOptions: { config: {} },
resources: [],
} as unknown as FastifyRequest
}
1 change: 1 addition & 0 deletions src/internal/http/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './agent'
export * from './partial-http-parser'
80 changes: 80 additions & 0 deletions src/internal/http/partial-http-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { getConfig } from '../../config'

const { isMultitenant, requestXForwardedHostRegExp } = getConfig()

const REQUEST_LINE_REGEX = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/[\d.]+)?$/i
const LINE_SPLIT_REGEX = /\r?\n/
// Validate header name (RFC 7230 token characters)
const HEADER_NAME_REGEX = /^[a-z0-9!#$%&'*+\-.^_`|~]+$/

const MAX_HEADER_LINES = 100

export interface PartialHttpData {
method: string
url: string
headers: Record<string, string>
tenantId: string
length: number
}

/**
* Parses partial HTTP request data from raw buffers.
* Returns defaults if parsing fails.
*/
export function parsePartialHttp(dataChunks: Buffer[]): PartialHttpData {
const result: PartialHttpData = {
method: 'UNKNOWN',
url: '/',
headers: {},
tenantId: isMultitenant ? 'unknown' : 'storage-single-tenant',
length: 0,
}

if (dataChunks.length === 0) {
return result
}

try {
const partialData = Buffer.concat(dataChunks).toString('utf8')
const lines = partialData.split(LINE_SPLIT_REGEX)
result.length = partialData.length

// Parse request line: "METHOD /path HTTP/version"
if (lines[0]) {
const requestLine = lines[0].match(REQUEST_LINE_REGEX)
if (requestLine) {
result.method = requestLine[1].toUpperCase()
result.url = requestLine[2]
}
}

// Parse headers (skip line 0, limit total lines)
const headerLineLimit = Math.min(lines.length, MAX_HEADER_LINES + 1)
for (let i = 1; i < headerLineLimit; i++) {
const line = lines[i]
if (!line || line.trim() === '') continue

const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const field = line.substring(0, colonIndex).trim().toLowerCase()
const value = line.substring(colonIndex + 1).trim()
if (HEADER_NAME_REGEX.test(field)) {
result.headers[field] = value
}
}
}

// Extract tenantId from x-forwarded-host if multitenant
if (isMultitenant && requestXForwardedHostRegExp && result.headers['x-forwarded-host']) {
const match = result.headers['x-forwarded-host'].match(requestXForwardedHostRegExp)
if (match && match[1]) {
result.tenantId = match[1]
}
}
} catch {
// Parsing failed - return defaults
// This catches malformed UTF-8, regex errors, etc.
}

return result
}
Loading