From c943d351c69c231ad2661df3870fc22b33381fe9 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:09:53 +0200 Subject: [PATCH] fix(core): Filter collected HTTP bodies and redact browser GraphQL document literals --- .../suites/express/tracing/test.ts | 8 +-- .../suites/express/without-tracing/test.ts | 8 +-- .../browser/src/integrations/graphqlClient.ts | 13 +++- .../test/integrations/graphqlClient.test.ts | 21 ++++++ packages/cloudflare/test/request.test.ts | 4 +- .../http/patch-request-to-capture-body.ts | 20 ++++-- .../utils/data-collection/filterHttpBody.ts | 64 ++++++++++++++++++ packages/core/src/utils/request.ts | 12 ++-- .../patch-request-to-capture-body.test.ts | 67 +++++++++++++++++++ .../data-collection/filterHttpBody.test.ts | 58 ++++++++++++++++ packages/core/test/lib/utils/request.test.ts | 46 ++++++++----- 11 files changed, 282 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/utils/data-collection/filterHttpBody.ts create mode 100644 packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts create mode 100644 packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index 8f86f80bbd04..85b72d6b100f 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -305,7 +305,8 @@ describe('express tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'text/plain', }, - data: 'some plain text', + // A plain-text body has no keys the denylist can match, so it is filtered completely. + data: '[Filtered]', }, }, }) @@ -330,7 +331,7 @@ describe('express tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - data: 'some plain text in buffer', + data: '[Filtered]', }, }, }) @@ -355,8 +356,7 @@ describe('express tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - // This is some non-ascii string representation - data: expect.any(String), + data: '[Filtered]', }, }, }) diff --git a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts index ea07c84226e3..d183c6892848 100644 --- a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts @@ -76,7 +76,8 @@ describe('express without tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'text/plain', }, - data: 'some plain text', + // A plain-text body has no keys the denylist can match, so it is filtered completely. + data: '[Filtered]', }, }, }) @@ -103,7 +104,7 @@ describe('express without tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - data: 'some plain text in buffer', + data: '[Filtered]', }, }, }) @@ -128,8 +129,7 @@ describe('express without tracing', () => { 'user-agent': expect.stringContaining(''), 'content-type': 'application/octet-stream', }, - // This is some non-ascii string representation - data: expect.any(String), + data: '[Filtered]', }, }, }) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 643ced098f0f..190b66a48792 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -52,6 +52,15 @@ interface GraphQLOperation { const INTEGRATION_NAME = 'GraphQLClient' as const; +// Matches the Int, Float, String, and BlockString literals in a document, the same set the +// server-side GraphQL integration redacts from the parsed AST. Names, enums, and booleans stay. +const GRAPHQL_LITERAL_RE = /"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g; + +/** Replaces every literal value in a raw GraphQL document, since literals can carry user data. */ +export function _redactGraphqlDocument(document: string): string { + return document.replace(GRAPHQL_LITERAL_RE, match => (match.startsWith('"') ? '"*"' : '*')); +} + const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { return { name: INTEGRATION_NAME, @@ -103,7 +112,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption // Handle standard requests - capture the query document when enabled via dataCollection (default true) if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { - span.setAttribute(GRAPHQL_DOCUMENT, graphqlBody.query); + span.setAttribute(GRAPHQL_DOCUMENT, _redactGraphqlDocument(graphqlBody.query)); } // Handle persisted operations - capture hash for debugging @@ -140,7 +149,7 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient data['graphql.operation'] = operationInfo; if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { - data[GRAPHQL_DOCUMENT] = graphqlBody.query; + data[GRAPHQL_DOCUMENT] = _redactGraphqlDocument(graphqlBody.query); } if (isPersistedRequest(graphqlBody)) { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 92dabd9adf53..24095d8478d5 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -10,12 +10,33 @@ import { URL_FULL } from '@sentry/conventions/attributes'; import { describe, expect, test } from 'vitest'; import { _getGraphQLOperation, + _redactGraphqlDocument, getGraphQLRequestPayload, getRequestPayloadXhrOrFetch, graphqlClientIntegration, parseGraphQLQuery, } from '../../src/integrations/graphqlClient'; +describe('_redactGraphqlDocument', () => { + test('replaces string and numeric literal arguments', () => { + expect(_redactGraphqlDocument('query { user(email: "jane@example.com", age: 42) { name } }')).toBe( + 'query { user(email: "*", age: *) { name } }', + ); + }); + + test('replaces block string literals', () => { + expect(_redactGraphqlDocument('mutation { post(body: """secret\nlines""") { id } }')).toBe( + 'mutation { post(body: "*") { id } }', + ); + }); + + test('leaves documents without literals untouched', () => { + const document = 'query Test($id: ID!) {\n people {\n name\n }\n}'; + + expect(_redactGraphqlDocument(document)).toBe(document); + }); +}); + describe('GraphqlClient', () => { describe('parseGraphQLQuery', () => { const queryOne = `query Test { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index adfd8c5f848b..a7af9300e864 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -343,7 +343,7 @@ describe('withSentry', () => { request: new Request('https://example.com', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ key: 'value' }), + body: JSON.stringify({ colour: 'blue' }), }), context, }, @@ -353,7 +353,7 @@ describe('withSentry', () => { }, ); - expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ key: 'value' })); + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ colour: 'blue' })); }); test('does not capture cookies when dataCollection.cookies is disabled', async () => { diff --git a/packages/core/src/integrations/http/patch-request-to-capture-body.ts b/packages/core/src/integrations/http/patch-request-to-capture-body.ts index 774d84eba7db..17056c3c50af 100644 --- a/packages/core/src/integrations/http/patch-request-to-capture-body.ts +++ b/packages/core/src/integrations/http/patch-request-to-capture-body.ts @@ -2,6 +2,8 @@ import type { Scope } from '../../scope'; import { debug } from '../../utils/debug-logger'; import { DEBUG_BUILD } from '../../debug-build'; import type { HttpIncomingMessage } from './types'; +import { FILTERED_VALUE } from '../../utils/data-collection/filtering-snippets'; +import { filterCollectedHttpBodyString } from '../../utils/data-collection/filterHttpBody'; import { getMaxBodyByteLength, type MaxRequestBodySize } from '../../utils/request'; /** @@ -19,6 +21,7 @@ export function patchRequestToCaptureBody( ): void { let bodyByteLength = 0; const chunks: Buffer[] = []; + let chunksDropped = false; DEBUG_BUILD && debug.log(integrationName, 'Patching request.on'); @@ -48,11 +51,13 @@ export function patchRequestToCaptureBody( if (bodyByteLength < maxBodySize) { chunks.push(bufferifiedChunk); bodyByteLength += bufferifiedChunk.byteLength; - } else if (DEBUG_BUILD) { - debug.log( - integrationName, - `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, - ); + } else { + chunksDropped = true; + DEBUG_BUILD && + debug.log( + integrationName, + `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, + ); } } catch { DEBUG_BUILD && debug.error(integrationName, 'Encountered error while storing body chunk.'); @@ -92,7 +97,10 @@ export function patchRequestToCaptureBody( req.on('end', () => { try { - const body = Buffer.concat(chunks).toString('utf-8'); + const rawBody = Buffer.concat(chunks).toString('utf-8'); + // The filter runs before truncation, because a truncated JSON body no longer parses. + // A capped stream is already incomplete, so its prefix is filtered without a parse attempt. + const body = rawBody && (chunksDropped ? FILTERED_VALUE : filterCollectedHttpBodyString(rawBody)); if (body) { // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long const bodyByteLength = Buffer.byteLength(body, 'utf-8'); diff --git a/packages/core/src/utils/data-collection/filterHttpBody.ts b/packages/core/src/utils/data-collection/filterHttpBody.ts new file mode 100644 index 000000000000..af98358e2849 --- /dev/null +++ b/packages/core/src/utils/data-collection/filterHttpBody.ts @@ -0,0 +1,64 @@ +import { isPlainObject } from '../is'; +import { FILTERED_VALUE } from './filtering-snippets'; +import { shouldFilterDataKey } from './filterKeyValueData'; +import { filterQueryParams } from './filterQueryParams'; + +/** Matches `key=value&key2=value2` bodies, the only non-JSON shape whose keys the denylist can check. */ +const FORM_BODY_RE = /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/; + +/** + * Scrubs an HTTP body the SDK collected itself, before it becomes `request.data` or + * `http.request.body.data`. A parseable body keeps its shape, and only the values of sensitive keys + * are replaced. An unparseable body has no keys to match, so the whole value becomes `[Filtered]`. + */ +export function filterCollectedHttpBody(body: unknown): unknown { + if (body == null) { + return body; + } + + if (typeof body === 'string') { + return filterCollectedHttpBodyString(body); + } + + // A `Buffer`, a stream, or a number has no keys to match, so the whole value is filtered. + return isPlainObject(body) || Array.isArray(body) ? filterBodyValue(body) : FILTERED_VALUE; +} + +/** + * String-only variant of {@link filterCollectedHttpBody}. Capture sites call this before they + * truncate, because a truncated JSON body no longer parses and would be dropped wholesale. + */ +export function filterCollectedHttpBodyString(body: string): string { + if (!body) { + return body; + } + + try { + const json: unknown = JSON.parse(body); + // A bare JSON scalar (`"hi"`, `42`) has no keys to match against, so it counts as unparseable. + if (typeof json === 'object' && json !== null) { + return JSON.stringify(filterBodyValue(json)); + } + } catch { + // Not JSON. The form-encoded attempt below runs instead. + } + + // The query-param filter keeps the body's original encoding byte-for-byte. + return (FORM_BODY_RE.test(body) && filterQueryParams(body, true)) || FILTERED_VALUE; +} + +function filterBodyValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(filterBodyValue); + } + + if (!isPlainObject(value)) { + return value; + } + + const result: Record = {}; + for (const [key, nested] of Object.entries(value)) { + result[key] = shouldFilterDataKey(key, true) ? FILTERED_VALUE : filterBodyValue(nested); + } + return result; +} diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index b013f09e8ce6..3abd130dd13b 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -7,6 +7,7 @@ import type { RequestEventData } from '../types/request'; import type { WebFetchHeaders, WebFetchRequest } from '../types/webfetchapi'; import { debug } from './debug-logger'; import { FILTERED_VALUE, SENSITIVE_COOKIE_NAME_SNIPPETS } from './data-collection/filtering-snippets'; +import { filterCollectedHttpBody, filterCollectedHttpBodyString } from './data-collection/filterHttpBody'; import { filterKeyValueData } from './data-collection/filterKeyValueData'; import { safeUnref } from './timer'; import { getUrlQuery } from './url'; @@ -158,17 +159,20 @@ export async function captureBodyFromWinterCGRequest( safeUnref(setTimeout(() => resolve(null), 2000)); }); - const body = await Promise.race([bodyPromise, timeoutPromise]); + const rawBody = await Promise.race([bodyPromise, timeoutPromise]); - if (body === null) { + if (rawBody === null) { DEBUG_BUILD && debug.log('Timeout reading request body'); return; } - if (!body) { + if (!rawBody) { return; } + // The filter runs before truncation, because a truncated JSON body no longer parses. + const body = filterCollectedHttpBodyString(rawBody); + // Using TextEncoder to get byte length for UTF-8 strings const encoder = new TextEncoder(); const bytes = encoder.encode(body); @@ -227,7 +231,7 @@ export function httpRequestToRequestData(request: { // This is non-standard, but may be sometimes set // It may be overwritten later by our own body handling - const data = (request as PolymorphicRequest).body || undefined; + const data = filterCollectedHttpBody((request as PolymorphicRequest).body || undefined); // This is non-standard, but may be set on e.g. Next.js or Express requests const cookies = (request as PolymorphicRequest).cookies; diff --git a/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts b/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts new file mode 100644 index 000000000000..219aabbaff2f --- /dev/null +++ b/packages/core/test/lib/integrations/http/patch-request-to-capture-body.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import { patchRequestToCaptureBody } from '../../../../src/integrations/http/patch-request-to-capture-body'; +import type { HttpIncomingMessage } from '../../../../src/integrations/http/types'; +import type { Scope } from '../../../../src/scope'; + +function makeFakeRequest(): { req: HttpIncomingMessage; emit: (event: string, ...args: unknown[]) => void } { + const listeners: Record void)[]> = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const req: any = { + on(event: string, cb: (...args: unknown[]) => void) { + (listeners[event] ??= []).push(cb); + return req; + }, + off(event: string, cb: (...args: unknown[]) => void) { + listeners[event] = (listeners[event] ?? []).filter(listener => listener !== cb); + return req; + }, + }; + req.addListener = req.on; + req.removeListener = req.off; + + return { + req: req as HttpIncomingMessage, + emit: (event, ...args) => (listeners[event] ?? []).slice().forEach(cb => cb(...args)), + }; +} + +function capture(chunks: string[]): ReturnType { + const setSDKProcessingMetadata = vi.fn(); + const scope = { setSDKProcessingMetadata } as unknown as Scope; + const { req, emit } = makeFakeRequest(); + + patchRequestToCaptureBody(req, scope, 'small', 'test'); + // The patch only records chunks when the app itself consumes the body. + req.on('data', () => {}); + chunks.forEach(chunk => emit('data', Buffer.from(chunk))); + emit('end'); + + return setSDKProcessingMetadata; +} + +function expectCapturedBody(spy: ReturnType, data: unknown): void { + expect(spy).toHaveBeenCalledWith({ normalizedRequest: { data } }); +} + +describe('patchRequestToCaptureBody', () => { + it('filters sensitive keys in a complete JSON body', () => { + expectCapturedBody(capture(['{"colour":"blue",', '"token":"abc"}']), '{"colour":"blue","token":"[Filtered]"}'); + }); + + it('keeps the filter-then-truncate order for a body that overshoots the limit in its final chunk', () => { + // 9 bytes of prefix + 988 kept characters + `...` = the 1000-byte `small` limit. + expectCapturedBody(capture([`{"note":"${'x'.repeat(1200)}"}`]), expect.stringMatching(/^\{"note":"x{988}\.\.\.$/)); + }); + + it('filters a capped stream wholesale, since the dropped chunks make it unparseable', () => { + expectCapturedBody(capture([`{"note":"${'x'.repeat(1200)}"}`, '{"more":"data"}']), '[Filtered]'); + }); + + it('filters a body that cannot be parsed into key-value pairs', () => { + expectCapturedBody(capture(['plain text body']), '[Filtered]'); + }); + + it('attaches nothing for an empty body', () => { + expect(capture([])).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts new file mode 100644 index 000000000000..27df04cb6daf --- /dev/null +++ b/packages/core/test/lib/utils/data-collection/filterHttpBody.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { filterCollectedHttpBody } from '../../../../src/utils/data-collection/filterHttpBody'; + +describe('filterCollectedHttpBody', () => { + it('passes through nullish and empty bodies untouched', () => { + expect(filterCollectedHttpBody(undefined)).toBeUndefined(); + expect(filterCollectedHttpBody(null)).toBeNull(); + expect(filterCollectedHttpBody('')).toBe(''); + }); + + describe('already parsed bodies', () => { + it('filters values for sensitive keys and keeps the rest', () => { + expect(filterCollectedHttpBody({ email: 'a@b.c', password: 'supersecret123' })).toEqual({ + email: 'a@b.c', + password: '[Filtered]', + }); + }); + + it('filters nested objects and arrays', () => { + expect(filterCollectedHttpBody({ users: [{ name: 'jane', api_key: 'abc' }] })).toEqual({ + users: [{ name: 'jane', api_key: '[Filtered]' }], + }); + }); + }); + + describe('JSON string bodies', () => { + it('filters sensitive keys and keeps the string shape', () => { + expect(filterCollectedHttpBody('{"colour":"blue","token":"abc"}')).toBe('{"colour":"blue","token":"[Filtered]"}'); + }); + + it('filters a bare JSON scalar, which has no keys to match against', () => { + expect(filterCollectedHttpBody('"just a string"')).toBe('[Filtered]'); + expect(filterCollectedHttpBody('42')).toBe('[Filtered]'); + }); + }); + + describe('form-encoded string bodies', () => { + it('filters sensitive keys while preserving the original encoding', () => { + expect(filterCollectedHttpBody('colour=blue&user%5Bpassword%5D=supersecret123')).toBe( + 'colour=blue&user%5Bpassword%5D=[Filtered]', + ); + }); + }); + + describe('unparseable bodies', () => { + it.each([['value'], ['plain text body'], ['query Test { people { name } }']])( + 'replaces %s with the filtered value', + body => { + expect(filterCollectedHttpBody(body)).toBe('[Filtered]'); + }, + ); + + it('replaces bodies that are not a key-value structure', () => { + expect(filterCollectedHttpBody(42)).toBe('[Filtered]'); + expect(filterCollectedHttpBody(Buffer.from('raw bytes'))).toBe('[Filtered]'); + }); + }); +}); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 4db75d5a96ff..eb2f4e9745c6 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -1095,6 +1095,18 @@ describe('request utils', () => { expect(scope.capturedData).toBe(jsonBody); }); + it('filters sensitive keys in a JSON body', async () => { + const request = createMockRequest({ + body: JSON.stringify({ colour: 'blue', api_token: 'abc' }), + contentType: 'application/json', + }); + const scope = createMockScope(); + + await captureBodyFromWinterCGRequest(request, scope, 'medium'); + + expect(scope.capturedData).toBe('{"colour":"blue","api_token":"[Filtered]"}'); + }); + it('captures form-urlencoded body', async () => { const request = createMockRequest({ body: 'username=test&password=secret', @@ -1104,10 +1116,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('username=test&password=secret'); + expect(scope.capturedData).toBe('username=test&password=[Filtered]'); }); - it('captures text/plain body', async () => { + it('filters a text/plain body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'Hello, World!', contentType: 'text/plain', @@ -1116,10 +1128,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('Hello, World!'); + expect(scope.capturedData).toBe('[Filtered]'); }); - it('captures text/html body', async () => { + it('filters a text/html body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'Test', contentType: 'text/html', @@ -1128,10 +1140,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('Test'); + expect(scope.capturedData).toBe('[Filtered]'); }); - it('captures application/xml body', async () => { + it('filters an application/xml body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'value', contentType: 'application/xml', @@ -1140,10 +1152,10 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('value'); + expect(scope.capturedData).toBe('[Filtered]'); }); - it('captures application/graphql body', async () => { + it('filters an application/graphql body, which has no keys to scrub by', async () => { const request = createMockRequest({ body: 'query { user { name } }', contentType: 'application/graphql', @@ -1152,7 +1164,7 @@ describe('request utils', () => { await captureBodyFromWinterCGRequest(request, scope, 'medium'); - expect(scope.capturedData).toBe('query { user { name } }'); + expect(scope.capturedData).toBe('[Filtered]'); }); it('skips non-textual content types', async () => { @@ -1221,10 +1233,10 @@ describe('request utils', () => { }); it('truncates body when it exceeds small size limit (1000 bytes)', async () => { - const largeBody = 'x'.repeat(2000); + const largeBody = `{"note":"${'x'.repeat(2000)}"}`; const request = createMockRequest({ body: largeBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope(); @@ -1235,10 +1247,10 @@ describe('request utils', () => { }); it('truncates body when it exceeds medium size limit (10000 bytes)', async () => { - const largeBody = 'x'.repeat(20000); + const largeBody = `{"note":"${'x'.repeat(20000)}"}`; const request = createMockRequest({ body: largeBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope(); @@ -1249,10 +1261,10 @@ describe('request utils', () => { }); it('does not truncate body within small size limit', async () => { - const smallBody = 'x'.repeat(500); + const smallBody = `{"note":"${'x'.repeat(500)}"}`; const request = createMockRequest({ body: smallBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope(); @@ -1275,10 +1287,10 @@ describe('request utils', () => { }); it('captures body with always size limit', async () => { - const largeBody = 'x'.repeat(50000); + const largeBody = `{"note":"${'x'.repeat(50000)}"}`; const request = createMockRequest({ body: largeBody, - contentType: 'text/plain', + contentType: 'application/json', }); const scope = createMockScope();