Skip to content

Commit 100369e

Browse files
mydeaAbhiPrasad
andauthored
feat(replay): Truncate network bodies to max size (#7875)
--------- Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent db013df commit 100369e

File tree

16 files changed

+914
-42
lines changed

16 files changed

+914
-42
lines changed

packages/replay/src/constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ export const ERROR_CHECKOUT_TIME = 60_000;
2929
export const RETRY_BASE_INTERVAL = 5000;
3030
export const RETRY_MAX_COUNT = 3;
3131

32-
/* The max (uncompressed) size in bytes of a network body. Any body larger than this will be dropped. */
33-
export const NETWORK_BODY_MAX_SIZE = 300_000;
32+
/* The max (uncompressed) size in bytes of a network body. Any body larger than this will be truncated. */
33+
export const NETWORK_BODY_MAX_SIZE = 150_000;

packages/replay/src/coreHandlers/util/fetchUtils.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { logger } from '@sentry/utils';
33

44
import type {
55
FetchHint,
6-
NetworkBody,
76
ReplayContainer,
87
ReplayNetworkOptions,
98
ReplayNetworkRequestData,
@@ -15,7 +14,6 @@ import {
1514
getAllowedHeaders,
1615
getBodySize,
1716
getBodyString,
18-
getNetworkBody,
1917
makeNetworkReplayBreadcrumb,
2018
parseContentLengthHeader,
2119
} from './networkUtils';
@@ -112,8 +110,8 @@ function _getRequestInfo(
112110

113111
// We only want to transmit string or string-like bodies
114112
const requestBody = _getFetchRequestArgBody(input);
115-
const body = getNetworkBody(getBodyString(requestBody));
116-
return buildNetworkRequestOrResponse(headers, requestBodySize, body);
113+
const bodyStr = getBodyString(requestBody);
114+
return buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr);
117115
}
118116

119117
async function _getResponseInfo(
@@ -137,15 +135,15 @@ async function _getResponseInfo(
137135
try {
138136
// We have to clone this, as the body can only be read once
139137
const res = response.clone();
140-
const { body, bodyText } = await _parseFetchBody(res);
138+
const bodyText = await _parseFetchBody(res);
141139

142140
const size =
143141
bodyText && bodyText.length && responseBodySize === undefined
144142
? getBodySize(bodyText, textEncoder)
145143
: responseBodySize;
146144

147145
if (captureBodies) {
148-
return buildNetworkRequestOrResponse(headers, size, body);
146+
return buildNetworkRequestOrResponse(headers, size, bodyText);
149147
}
150148

151149
return buildNetworkRequestOrResponse(headers, size, undefined);
@@ -155,25 +153,12 @@ async function _getResponseInfo(
155153
}
156154
}
157155

158-
async function _parseFetchBody(
159-
response: Response,
160-
): Promise<{ body?: NetworkBody | undefined; bodyText?: string | undefined }> {
161-
let bodyText: string;
162-
156+
async function _parseFetchBody(response: Response): Promise<string | undefined> {
163157
try {
164-
bodyText = await response.text();
158+
return await response.text();
165159
} catch {
166-
return {};
167-
}
168-
169-
try {
170-
const body = JSON.parse(bodyText);
171-
return { body, bodyText };
172-
} catch {
173-
// just send bodyText
160+
return undefined;
174161
}
175-
176-
return { bodyText, body: bodyText };
177162
}
178163

179164
function _getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined {

packages/replay/src/coreHandlers/util/networkUtils.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { dropUndefinedKeys } from '@sentry/utils';
44
import { NETWORK_BODY_MAX_SIZE } from '../../constants';
55
import type {
66
NetworkBody,
7+
NetworkMetaWarning,
78
NetworkRequestData,
89
ReplayNetworkRequestData,
910
ReplayNetworkRequestOrResponse,
1011
ReplayPerformanceEntry,
1112
} from '../../types';
13+
import { fixJson } from '../../util/truncateJson/fixJson';
1214

1315
/** Get the size of a body. */
1416
export function getBodySize(
@@ -122,7 +124,7 @@ export function getNetworkBody(bodyText: string | undefined): NetworkBody | unde
122124
export function buildNetworkRequestOrResponse(
123125
headers: Record<string, string>,
124126
bodySize: number | undefined,
125-
body: NetworkBody | undefined,
127+
body: string | undefined,
126128
): ReplayNetworkRequestOrResponse | undefined {
127129
if (!bodySize && Object.keys(headers).length === 0) {
128130
return undefined;
@@ -146,11 +148,11 @@ export function buildNetworkRequestOrResponse(
146148
size: bodySize,
147149
};
148150

149-
if (bodySize < NETWORK_BODY_MAX_SIZE) {
150-
info.body = body;
151-
} else {
151+
const { body: normalizedBody, warnings } = normalizeNetworkBody(body);
152+
info.body = normalizedBody;
153+
if (warnings.length > 0) {
152154
info._meta = {
153-
errors: ['MAX_BODY_SIZE_EXCEEDED'],
155+
warnings,
154156
};
155157
}
156158

@@ -175,3 +177,46 @@ function _serializeFormData(formData: FormData): string {
175177
// @ts-ignore passing FormData to URLSearchParams actually works
176178
return new URLSearchParams(formData).toString();
177179
}
180+
181+
function normalizeNetworkBody(body: string | undefined): {
182+
body: NetworkBody | undefined;
183+
warnings: NetworkMetaWarning[];
184+
} {
185+
if (!body || typeof body !== 'string') {
186+
return {
187+
body,
188+
warnings: [],
189+
};
190+
}
191+
192+
const exceedsSizeLimit = body.length > NETWORK_BODY_MAX_SIZE;
193+
194+
if (_strIsProbablyJson(body)) {
195+
try {
196+
const json = exceedsSizeLimit ? fixJson(body.slice(0, NETWORK_BODY_MAX_SIZE)) : body;
197+
const normalizedBody = JSON.parse(json);
198+
return {
199+
body: normalizedBody,
200+
warnings: exceedsSizeLimit ? ['JSON_TRUNCATED'] : [],
201+
};
202+
} catch {
203+
return {
204+
body,
205+
warnings: ['INVALID_JSON'],
206+
};
207+
}
208+
}
209+
210+
return {
211+
body: exceedsSizeLimit ? `${body.slice(0, NETWORK_BODY_MAX_SIZE)}…` : body,
212+
warnings: exceedsSizeLimit ? ['TEXT_TRUNCATED'] : [],
213+
};
214+
}
215+
216+
function _strIsProbablyJson(str: string): boolean {
217+
const first = str[0];
218+
const last = str[str.length - 1];
219+
220+
// Simple check: If this does not start & end with {} or [], it's not JSON
221+
return (first === '[' && last === ']') || (first === '{' && last === '}');
222+
}

packages/replay/src/coreHandlers/util/xhrUtils.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
getAllowedHeaders,
99
getBodySize,
1010
getBodyString,
11-
getNetworkBody,
1211
makeNetworkReplayBreadcrumb,
1312
parseContentLengthHeader,
1413
} from './networkUtils';
@@ -84,12 +83,12 @@ function _prepareXhrData(
8483
const request = buildNetworkRequestOrResponse(
8584
requestHeaders,
8685
requestBodySize,
87-
options.captureBodies ? getNetworkBody(getBodyString(input)) : undefined,
86+
options.captureBodies ? getBodyString(input) : undefined,
8887
);
8988
const response = buildNetworkRequestOrResponse(
9089
responseHeaders,
9190
responseBodySize,
92-
options.captureBodies ? getNetworkBody(hint.xhr.responseText) : undefined,
91+
options.captureBodies ? hint.xhr.responseText : undefined,
9392
);
9493

9594
return {

packages/replay/src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,12 +512,15 @@ export type FetchHint = FetchBreadcrumbHint & {
512512
response: Response;
513513
};
514514

515-
export type NetworkBody = Record<string, unknown> | string;
515+
type JsonObject = Record<string, unknown>;
516+
type JsonArray = unknown[];
516517

517-
type NetworkMetaError = 'MAX_BODY_SIZE_EXCEEDED';
518+
export type NetworkBody = JsonObject | JsonArray | string;
519+
520+
export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON';
518521

519522
interface NetworkMeta {
520-
errors?: NetworkMetaError[];
523+
warnings?: NetworkMetaWarning[];
521524
}
522525

523526
export interface ReplayNetworkRequestOrResponse {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { JsonToken } from './constants';
2+
import {
3+
ARR,
4+
ARR_VAL,
5+
ARR_VAL_COMPLETED,
6+
ARR_VAL_STR,
7+
OBJ,
8+
OBJ_KEY,
9+
OBJ_KEY_STR,
10+
OBJ_VAL,
11+
OBJ_VAL_COMPLETED,
12+
OBJ_VAL_STR,
13+
} from './constants';
14+
15+
const ALLOWED_PRIMITIVES = ['true', 'false', 'null'];
16+
17+
/**
18+
* Complete an incomplete JSON string.
19+
* This will ensure that the last element always has a `"~~"` to indicate it was truncated.
20+
* For example, `[1,2,` will be completed to `[1,2,"~~"]`
21+
* and `{"aa":"b` will be completed to `{"aa":"b~~"}`
22+
*/
23+
export function completeJson(incompleteJson: string, stack: JsonToken[]): string {
24+
if (!stack.length) {
25+
return incompleteJson;
26+
}
27+
28+
let json = incompleteJson;
29+
30+
// Most checks are only needed for the last step in the stack
31+
const lastPos = stack.length - 1;
32+
const lastStep = stack[lastPos];
33+
34+
json = _fixLastStep(json, lastStep);
35+
36+
// Complete remaining steps - just add closing brackets
37+
for (let i = lastPos; i >= 0; i--) {
38+
const step = stack[i];
39+
40+
switch (step) {
41+
case OBJ:
42+
json = `${json}}`;
43+
break;
44+
case ARR:
45+
json = `${json}]`;
46+
break;
47+
}
48+
}
49+
50+
return json;
51+
}
52+
53+
function _fixLastStep(json: string, lastStep: JsonToken): string {
54+
switch (lastStep) {
55+
// Object cases
56+
case OBJ:
57+
return `${json}"~~":"~~"`;
58+
case OBJ_KEY:
59+
return `${json}:"~~"`;
60+
case OBJ_KEY_STR:
61+
return `${json}~~":"~~"`;
62+
case OBJ_VAL:
63+
return _maybeFixIncompleteObjValue(json);
64+
case OBJ_VAL_STR:
65+
return `${json}~~"`;
66+
case OBJ_VAL_COMPLETED:
67+
return `${json},"~~":"~~"`;
68+
69+
// Array cases
70+
case ARR:
71+
return `${json}"~~"`;
72+
case ARR_VAL:
73+
return _maybeFixIncompleteArrValue(json);
74+
case ARR_VAL_STR:
75+
return `${json}~~"`;
76+
case ARR_VAL_COMPLETED:
77+
return `${json},"~~"`;
78+
}
79+
80+
return json;
81+
}
82+
83+
function _maybeFixIncompleteArrValue(json: string): string {
84+
const pos = _findLastArrayDelimiter(json);
85+
86+
if (pos > -1) {
87+
const part = json.slice(pos + 1);
88+
89+
if (ALLOWED_PRIMITIVES.includes(part.trim())) {
90+
return `${json},"~~"`;
91+
}
92+
93+
// Everything else is replaced with `"~~"`
94+
return `${json.slice(0, pos + 1)}"~~"`;
95+
}
96+
97+
// fallback, this shouldn't happen, to be save
98+
return json;
99+
}
100+
101+
function _findLastArrayDelimiter(json: string): number {
102+
for (let i = json.length - 1; i >= 0; i--) {
103+
const char = json[i];
104+
105+
if (char === ',' || char === '[') {
106+
return i;
107+
}
108+
}
109+
110+
return -1;
111+
}
112+
113+
function _maybeFixIncompleteObjValue(json: string): string {
114+
const startPos = json.lastIndexOf(':');
115+
116+
const part = json.slice(startPos + 1);
117+
118+
if (ALLOWED_PRIMITIVES.includes(part.trim())) {
119+
return `${json},"~~":"~~"`;
120+
}
121+
122+
// Everything else is replaced with `"~~"`
123+
// This also means we do not have incomplete numbers, e.g `[1` is replaced with `["~~"]`
124+
return `${json.slice(0, startPos + 1)}"~~"`;
125+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export const OBJ = 10;
2+
export const OBJ_KEY = 11;
3+
export const OBJ_KEY_STR = 12;
4+
export const OBJ_VAL = 13;
5+
export const OBJ_VAL_STR = 14;
6+
export const OBJ_VAL_COMPLETED = 15;
7+
8+
export const ARR = 20;
9+
export const ARR_VAL = 21;
10+
export const ARR_VAL_STR = 22;
11+
export const ARR_VAL_COMPLETED = 23;
12+
13+
export type JsonToken =
14+
| typeof OBJ
15+
| typeof OBJ_KEY
16+
| typeof OBJ_KEY_STR
17+
| typeof OBJ_VAL
18+
| typeof OBJ_VAL_STR
19+
| typeof OBJ_VAL_COMPLETED
20+
| typeof ARR
21+
| typeof ARR_VAL
22+
| typeof ARR_VAL_STR
23+
| typeof ARR_VAL_COMPLETED;

0 commit comments

Comments
 (0)