diff --git a/src/pyroscope-api-exporter.ts b/src/pyroscope-api-exporter.ts index aea636e..d447640 100644 --- a/src/pyroscope-api-exporter.ts +++ b/src/pyroscope-api-exporter.ts @@ -3,6 +3,10 @@ import { URL } from 'node:url'; import { encode } from '@datadog/pprof'; import { Profile } from 'pprof-format'; import { ProfileExport, ProfileExporter } from './profile-exporter.js'; +import { + buildProfileMultipartBody, + MultipartRequestBody, +} from './utils/build-profile-multipart-body.js'; import { dateToUnixTimestamp } from './utils/date-to-unix-timestamp.js'; import { processProfile } from './utils/process-profile.js'; import debug from 'debug'; @@ -93,9 +97,9 @@ export class PyroscopeApiExporter implements ProfileExporter { return arrayBuffer; } - private async buildUploadProfileFormData( + private async buildUploadProfileRequestBody( profile: Profile - ): Promise { + ): Promise { const processedProfile: Profile = processProfile(profile, { stripFilenames: this.config.stripFilenames, shortenPaths: this.config.shortenPaths, @@ -104,30 +108,34 @@ export class PyroscopeApiExporter implements ProfileExporter { const arrayBuffer: Uint8Array = this.buildArrayBuffer(profileBuffer); - const formData: FormData = new FormData(); - formData.append('profile', new Blob([arrayBuffer]), 'profile'); - - return formData; + return buildProfileMultipartBody(arrayBuffer); } private async uploadProfile(profileExport: ProfileExport): Promise { - const formData: FormData = await this.buildUploadProfileFormData( - profileExport.profile - ); + const { body, contentType }: MultipartRequestBody = + await this.buildUploadProfileRequestBody(profileExport.profile); + + const headers: Headers = this.buildRequestHeaders(); + headers.set('content-type', contentType); try { const response = await fetch( this.buildEndpointUrl(profileExport).toString(), { - body: formData, - headers: this.buildRequestHeaders(), + body, + headers, method: 'POST', } ); + // Always drain the response body. The success path (HTTP 200) previously + // left the body unconsumed, which keeps the connection from being + // released back to the pool promptly and retains the response buffer. + const responseText: string = await response.text(); + if (!response.ok) { log('Server rejected data ingest: HTTP %d', response.status); - log(await response.text()); + log(responseText); } } catch (error: unknown) { if (error instanceof Error) { diff --git a/src/utils/build-profile-multipart-body.ts b/src/utils/build-profile-multipart-body.ts new file mode 100644 index 0000000..14b1196 --- /dev/null +++ b/src/utils/build-profile-multipart-body.ts @@ -0,0 +1,51 @@ +import { randomBytes } from 'node:crypto'; + +const CRLF = '\r\n'; + +export interface MultipartRequestBody { + body: Uint8Array; + contentType: string; +} + +// Serializes a single `profile` part into a multipart/form-data request body +// as a plain, fully-materialized Uint8Array instead of a `FormData` + `Blob`. +// +// WHY this is a hand-rolled buffer rather than `FormData`: +// +// When the request body is a `FormData` containing a `Blob`, undici's `fetch` +// serializes each blob part by calling `blob.stream()`. On Node 24.16.0 - +// 24.18.0 `Blob.prototype.stream()` pins the source `ArrayBuffer` in an +// eternal (never-released) handle, so every flush leaks one payload-sized +// ArrayBuffer. With two profilers flushing on the default interval this is a +// steady, monotonic native-memory leak (~45-80 MB/day in production) that a +// long-lived process never recovers. +// - node core bug: https://github.com/nodejs/node/issues/63574 +// - core fix (main, not yet on 24.x): https://github.com/nodejs/node/pull/63577 +// - 24.x backport tracking: https://github.com/nodejs/node/issues/64105 +// +// Passing a typed-array body sidesteps `Blob.stream()` entirely, so the leak +// cannot occur on any Node version. It also avoids a wasteful +// Buffer -> Blob -> stream round-trip on every flush on all versions. The bytes +// produced here are wire-identical to what undici emits for the equivalent +// `FormData`/`Blob` (same part framing, headers, and CRLF placement); only the +// boundary token differs, which is arbitrary by design. +export function buildProfileMultipartBody( + content: Uint8Array +): MultipartRequestBody { + const boundary = `----pyroscope${randomBytes(16).toString('hex')}`; + + const header: Buffer = Buffer.from( + `--${boundary}${CRLF}` + + `Content-Disposition: form-data; name="profile"; filename="profile"${CRLF}` + + `Content-Type: application/octet-stream${CRLF}${CRLF}`, + 'utf8' + ); + const footer: Buffer = Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8'); + + const body: Buffer = Buffer.concat([header, content, footer]); + + return { + body: new Uint8Array(body.buffer, body.byteOffset, body.byteLength), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} diff --git a/test/build-profile-multipart-body.test.ts b/test/build-profile-multipart-body.test.ts new file mode 100644 index 0000000..a2ed5f5 --- /dev/null +++ b/test/build-profile-multipart-body.test.ts @@ -0,0 +1,83 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { randomBytes } from 'node:crypto'; +import { Readable } from 'node:stream'; + +import busboy from 'busboy'; + +import { buildProfileMultipartBody } from '../src/utils/build-profile-multipart-body.js'; + +interface ParsedPart { + name: string; + filename: string | undefined; + mimeType: string; + data: Buffer; +} + +// Parse a multipart/form-data payload with busboy, the same parser the +// profiler integration tests use to decode uploads. +const parseMultipart = ( + body: Uint8Array, + contentType: string +): Promise => + new Promise((resolve, reject) => { + const bb = busboy({ headers: { 'content-type': contentType } }); + const partPromises: Promise[] = []; + + bb.on('file', (name, stream, info) => { + partPromises.push( + stream.toArray().then((chunks: Buffer[]) => ({ + name, + filename: info.filename, + mimeType: info.mimeType, + data: Buffer.concat(chunks), + })) + ); + }); + // 'close' can fire before the per-file toArray() promises settle, so wait + // on them before resolving. + bb.on('close', () => { + Promise.all(partPromises).then(resolve).catch(reject); + }); + bb.on('error', reject); + + Readable.from(Buffer.from(body)).pipe(bb); + }); + +describe('buildProfileMultipartBody', () => { + it('sets a multipart/form-data content type with a pyroscope boundary', () => { + const { contentType } = buildProfileMultipartBody( + new Uint8Array([1, 2, 3]) + ); + + assert.match( + contentType, + /^multipart\/form-data; boundary=----pyroscope[0-9a-f]{32}$/ + ); + }); + + it('serializes the profile part with bytes intact (byte-equal to input)', async () => { + // Random bytes exercise binary content, including sequences that could + // resemble CRLF/boundary framing. + const content = new Uint8Array(randomBytes(4096).buffer); + + const { body, contentType } = buildProfileMultipartBody(content); + const parts = await parseMultipart(body, contentType); + + assert.equal(parts.length, 1); + const [part] = parts; + assert.equal(part.name, 'profile'); + assert.equal(part.filename, 'profile'); + assert.equal(part.mimeType, 'application/octet-stream'); + // Byte-for-byte equality with the buffer handed to the serializer. + assert.deepEqual(new Uint8Array(part.data), content); + }); + + it('produces a body that is not a Blob (avoids Blob.stream leak)', () => { + const { body } = buildProfileMultipartBody( + new Uint8Array([0, 255, 10, 13]) + ); + + assert.ok(body instanceof Uint8Array); + }); +});