UNPKG

@multiplayer-app/session-recorder-common

Version:
376 lines (323 loc) 11.5 kB
import type { IncomingMessage, ServerResponse, ClientRequest, } from 'http' import * as zlib from 'zlib' import type { Span } from '@opentelemetry/api' import { ATTR_MULTIPLAYER_HTTP_REQUEST_BODY, ATTR_MULTIPLAYER_HTTP_REQUEST_HEADERS, ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY, ATTR_MULTIPLAYER_HTTP_RESPONSE_HEADERS, MULTIPLAYER_MAX_HTTP_REQUEST_RESPONSE_SIZE, ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY_ENCODING, MULTIPLAYER_TRACE_DEBUG_PREFIX, MULTIPLAYER_TRACE_DOC_PREFIX, } from '../constants/constants.node' import { mask, schemify, isGzip, } from '../sdk' import { sensitiveFields, sensitiveHeaders, } from '../sdk/mask' interface HttpResponseHookOptions { maxPayloadSizeBytes?: number schemifyDocSpanPayload?: boolean uncompressPayload?: boolean captureHeaders?: boolean captureBody?: boolean isMaskBodyEnabled?: boolean isMaskHeadersEnabled?: boolean maskBody?: (arg: any, span: Span) => any maskHeaders?: (arg: any, span: Span) => any maskBodyFieldsList?: string[] maskHeadersList?: string[] headersToInclude?: string[] headersToExclude?: string[] } interface HttpRequestHookOptions { maxPayloadSizeBytes?: number schemifyDocSpanPayload?: boolean captureHeaders?: boolean captureBody?: boolean isMaskBodyEnabled?: boolean isMaskHeadersEnabled?: boolean maskBody?: (arg: any, span: Span) => any maskHeaders?: (arg: any, span: Span) => any maskBodyFieldsList?: string[] maskHeadersList?: string[] headersToInclude?: string[] headersToExclude?: string[] } const setDefaultOptions = ( options: HttpResponseHookOptions | HttpResponseHookOptions, ): Omit<HttpResponseHookOptions & HttpResponseHookOptions, 'maskBody' | 'maskHeaders'> & { maskBody: (arg: any, span: Span) => any maskHeaders: (arg: any, span: Span) => any captureHeaders: boolean, captureBody: boolean, isMaskBodyEnabled: boolean isMaskHeadersEnabled: boolean schemifyDocSpanPayload: boolean, uncompressPayload: boolean, maxPayloadSizeBytes: number } => { options.captureHeaders = 'captureHeaders' in options ? options.captureHeaders : true options.captureBody = 'captureBody' in options ? options.captureBody : true options.isMaskBodyEnabled = 'isMaskBodyEnabled' in options ? options.isMaskBodyEnabled : true options.isMaskHeadersEnabled = 'isMaskHeadersEnabled' in options ? options.isMaskHeadersEnabled : true options.schemifyDocSpanPayload = 'schemifyDocSpanPayload' in options ? options.schemifyDocSpanPayload : false options.uncompressPayload = 'uncompressPayload' in options ? options.uncompressPayload : true options.maskBody = options.maskBody || mask([ ...( Array.isArray(options.maskBodyFieldsList) ? options.maskBodyFieldsList : sensitiveFields ), ...( Array.isArray(options.maskHeadersList) ? options.maskHeadersList : sensitiveHeaders ), ]) options.maskHeaders = options.maskHeaders || mask([ ...( Array.isArray(options.maskBodyFieldsList) ? options.maskBodyFieldsList : sensitiveFields ), ...( Array.isArray(options.maskHeadersList) ? options.maskHeadersList : sensitiveHeaders ), ]) options.maxPayloadSizeBytes = options.maxPayloadSizeBytes || MULTIPLAYER_MAX_HTTP_REQUEST_RESPONSE_SIZE return options as Omit<HttpResponseHookOptions & HttpResponseHookOptions, 'maskBody' | 'maskHeaders'> & { maskBody: (arg: any, span: Span) => any maskHeaders: (arg: any, span: Span) => any captureHeaders: boolean, captureBody: boolean, isMaskBodyEnabled: boolean, isMaskHeadersEnabled: boolean, schemifyDocSpanPayload: boolean, uncompressPayload: boolean, maxPayloadSizeBytes: number } } export const SessionRecorderHttpInstrumentationHooksNode = { responseHook: (options: HttpResponseHookOptions = {}) => (span: Span, response: IncomingMessage | ServerResponse) => { try { const _options = setDefaultOptions(options) if (!_options.captureBody && !_options.captureHeaders) { return } const _response = response as ServerResponse const traceId = span.spanContext().traceId if (_response.setHeader) { _response.setHeader('X-Trace-Id', traceId) } const [oldWrite, oldEnd] = [_response.write, _response.end] const chunks: Buffer[] = [] if (_options.captureBody) { (_response.write as unknown) = function (...restArgs: any[]) { chunks.push(Buffer.from(restArgs[0])) // @ts-ignore oldWrite.apply(_response, restArgs) } } // @ts-ignore _response.end = async function (...restArgs) { if (_options.captureBody && restArgs[0]) { chunks.push(Buffer.from(restArgs[0])) } const responseBuffer = Buffer.concat(chunks) if ( _options.captureBody && responseBuffer.byteLength > 0 && responseBuffer.byteLength < _options.maxPayloadSizeBytes ) { let responseBody: string let skipResponseBodyModification = false if (isGzip(responseBuffer)) { if (_options.uncompressPayload) { const dezippedBuffer = await new Promise((resolve) => zlib .gunzip(responseBuffer, function (err, dezipped) { if (err) { return resolve(Buffer.from('')) } else { return resolve(dezipped) } })) as Buffer responseBody = dezippedBuffer.toString('utf-8') } else { span.setAttribute( ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY_ENCODING, 'gzip', ) skipResponseBodyModification = true responseBody = responseBuffer.toString('hex') } } else { responseBody = responseBuffer.toString('utf-8') } if (responseBody.length) { responseBody = JSON.parse(JSON.stringify(responseBody)) } if (!skipResponseBodyModification) { if ( traceId.startsWith(MULTIPLAYER_TRACE_DEBUG_PREFIX) && _options.isMaskBodyEnabled ) { responseBody = _options.maskBody(responseBody, span) } else if ( traceId.startsWith(MULTIPLAYER_TRACE_DOC_PREFIX) && _options.schemifyDocSpanPayload ) { responseBody = schemify(responseBody) } else if (typeof responseBody !== 'string') { responseBody = JSON.stringify(responseBody) } } if (responseBody.length) { span.setAttribute( ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY, responseBody, ) } } if (_options.captureHeaders) { let _headers = JSON.parse(JSON.stringify(_response.getHeaders() || {})) if (_options.isMaskHeadersEnabled) { _headers = _options.maskHeaders(_headers, span) } if (_options.headersToInclude) { const filteredHeaders: any = {} for (const headerName of _options.headersToInclude) { filteredHeaders[headerName] = _headers[headerName] } _headers = filteredHeaders } if (_options.headersToExclude?.length) { for (const headerName of _options.headersToExclude) { delete _headers[headerName] } } const stringifiedHeaders = JSON.stringify(_headers) if (stringifiedHeaders?.length) { span.setAttribute( ATTR_MULTIPLAYER_HTTP_RESPONSE_HEADERS, stringifiedHeaders, ) } } // @ts-ignore return oldEnd.apply(_response, restArgs) } } catch (error) { // eslint-disable-next-line console.error('An error occured in multiplayer otlp http responseHook', error) } }, requestHook: (options: HttpRequestHookOptions = {}) => (span: Span, request: ClientRequest | IncomingMessage) => { try { const _options = setDefaultOptions(options) if (!_options.captureBody && !_options.captureHeaders) { return } const traceId = span.spanContext().traceId const _request = request as IncomingMessage if (_options.captureHeaders) { let _headers = JSON.parse(JSON.stringify(_request.headers || {})) if (_options.isMaskHeadersEnabled) { _headers = _options.maskHeaders(_headers, span) } if (_options.headersToInclude) { const filteredHeaders: any = {} for (const headerName of _options.headersToInclude) { filteredHeaders[headerName] = _headers[headerName] } _headers = filteredHeaders } if (_options.headersToExclude?.length) { for (const headerName of _options.headersToExclude) { delete _headers[headerName] } } span.setAttribute( ATTR_MULTIPLAYER_HTTP_REQUEST_HEADERS, JSON.stringify(_headers), ) } const contentType = _request?.headers?.['content-type'] if ( _options.captureBody && contentType?.includes('application/json') ) { let body = '' _request.on('data', (chunk) => { body += chunk }) _request.on('end', () => { try { const requestBodySizeBytes = Buffer.byteLength(body, 'utf8') if ( requestBodySizeBytes === 0 || requestBodySizeBytes > _options.maxPayloadSizeBytes ) { return } let requestBody = body if (!requestBody) return requestBody = JSON.parse(JSON.stringify(requestBody)) if ( traceId.startsWith(MULTIPLAYER_TRACE_DEBUG_PREFIX) && _options.isMaskBodyEnabled ) { requestBody = _options.maskBody(requestBody, span) } else if ( traceId.startsWith(MULTIPLAYER_TRACE_DOC_PREFIX) && _options.schemifyDocSpanPayload ) { requestBody = schemify(requestBody) } else if (typeof requestBody !== 'string') { requestBody = JSON.stringify(requestBody) } if (requestBody?.length) { span.setAttribute( ATTR_MULTIPLAYER_HTTP_REQUEST_BODY, requestBody, ) } } catch (err) { // eslint-disable-next-line console.error('[MULTIPLAYER-HTTP-REQ-HOOK] An error occured in multiplayer otlp http requestHook', err) } }) } } catch (error) { // eslint-disable-next-line console.error('An error occured in multiplayer otlp http requestHook', error) } }, }