Skip to content

Commit cd1318b

Browse files
committed
feat(replay): Fix truncated JSON bodies
1 parent 6fe5f60 commit cd1318b

File tree

8 files changed

+601
-10
lines changed

8 files changed

+601
-10
lines changed

static/app/utils/replays/replay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ type JsonObject = Record<string, unknown>;
22
type JsonArray = unknown[];
33

44
export type NetworkMetaWarning =
5+
| 'MAYBE_JSON_TRUNCATED'
56
| 'JSON_TRUNCATED'
67
| 'TEXT_TRUNCATED'
78
| 'INVALID_JSON'

static/app/views/replays/detail/network/details/components.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ const WarningText = styled('span')`
2020
color: ${p => p.theme.errorText};
2121
`;
2222

23-
export function Warning({warnings}: {warnings: undefined | string[]}) {
24-
if (warnings?.includes('JSON_TRUNCATED') || warnings?.includes('TEXT_TRUNCATED')) {
23+
export function Warning({warnings}: {warnings: string[]}) {
24+
if (warnings.includes('JSON_TRUNCATED') || warnings.includes('TEXT_TRUNCATED')) {
2525
return (
2626
<WarningText>{t('Truncated (~~) due to exceeding 150k characters')}</WarningText>
2727
);
2828
}
2929

30-
if (warnings?.includes('INVALID_JSON')) {
30+
if (warnings.includes('INVALID_JSON')) {
3131
return <WarningText>{t('Invalid JSON')}</WarningText>;
3232
}
3333

static/app/views/replays/detail/network/details/sections.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {useReplayContext} from 'sentry/components/replays/replayContext';
88
import {t} from 'sentry/locale';
99
import {space} from 'sentry/styles/space';
1010
import {formatBytesBase10} from 'sentry/utils';
11+
import {
12+
NetworkMetaWarning,
13+
ReplayNetworkRequestOrResponse,
14+
} from 'sentry/utils/replays/replay';
1115
import {
1216
getFrameMethod,
1317
getFrameStatus,
@@ -24,6 +28,7 @@ import {
2428
Warning,
2529
} from 'sentry/views/replays/detail/network/details/components';
2630
import {useDismissReqRespBodiesAlert} from 'sentry/views/replays/detail/network/details/onboarding';
31+
import {fixJson} from 'sentry/views/replays/detail/network/truncateJson/fixJson';
2732
import TimestampButton from 'sentry/views/replays/detail/timestampButton';
2833

2934
export type SectionProps = {
@@ -39,9 +44,6 @@ export function GeneralSection({item, startTimestampMs}: SectionProps) {
3944

4045
const requestFrame = isRequestFrame(item) ? item : null;
4146

42-
// TODO[replay]: what about:
43-
// `requestFrame?.data?.request?.size` vs. `requestFrame?.data?.requestBodySize`
44-
4547
const data: KeyValueTuple[] = [
4648
{key: t('URL'), value: item.description},
4749
{key: t('Type'), value: item.op},
@@ -179,6 +181,8 @@ export function RequestPayloadSection({item}: SectionProps) {
179181
const {dismiss, isDismissed} = useDismissReqRespBodiesAlert();
180182

181183
const data = useMemo(() => (isRequestFrame(item) ? item.data : {}), [item]);
184+
const {warnings, body} = getBodyAndWarnings(data.request);
185+
182186
useEffect(() => {
183187
if (!isDismissed && 'request' in data) {
184188
dismiss();
@@ -195,9 +199,9 @@ export function RequestPayloadSection({item}: SectionProps) {
195199
}
196200
>
197201
<Indent>
198-
<Warning warnings={data.request?._meta?.warnings} />
202+
<Warning warnings={warnings} />
199203
{'request' in data ? (
200-
<ObjectInspector data={data.request?.body} expandLevel={2} showCopyButton />
204+
<ObjectInspector data={body} expandLevel={2} showCopyButton />
201205
) : (
202206
t('Request body not found.')
203207
)}
@@ -210,6 +214,8 @@ export function ResponsePayloadSection({item}: SectionProps) {
210214
const {dismiss, isDismissed} = useDismissReqRespBodiesAlert();
211215

212216
const data = useMemo(() => (isRequestFrame(item) ? item.data : {}), [item]);
217+
const {warnings, body} = getBodyAndWarnings(data.response);
218+
213219
useEffect(() => {
214220
if (!isDismissed && 'response' in data) {
215221
dismiss();
@@ -226,13 +232,39 @@ export function ResponsePayloadSection({item}: SectionProps) {
226232
}
227233
>
228234
<Indent>
229-
<Warning warnings={data?.response?._meta?.warnings} />
235+
<Warning warnings={warnings} />
230236
{'response' in data ? (
231-
<ObjectInspector data={data.response?.body} expandLevel={2} showCopyButton />
237+
<ObjectInspector data={body} expandLevel={2} showCopyButton />
232238
) : (
233239
t('Response body not found.')
234240
)}
235241
</Indent>
236242
</SectionItem>
237243
);
238244
}
245+
246+
function getBodyAndWarnings(reqOrRes?: ReplayNetworkRequestOrResponse): {
247+
body: ReplayNetworkRequestOrResponse['body'];
248+
warnings: NetworkMetaWarning[];
249+
} {
250+
if (!reqOrRes) {
251+
return {body: undefined, warnings: []};
252+
}
253+
254+
const warnings = reqOrRes._meta?.warnings ?? [];
255+
let body = reqOrRes.body;
256+
257+
if (typeof body === 'string' && warnings.includes('MAYBE_JSON_TRUNCATED')) {
258+
try {
259+
const json = fixJson(body);
260+
body = JSON.parse(json);
261+
warnings.push('JSON_TRUNCATED');
262+
} catch {
263+
// this can fail, in which case we just use the body string
264+
warnings.push('INVALID_JSON');
265+
warnings.push('TEXT_TRUNCATED');
266+
}
267+
}
268+
269+
return {body, warnings};
270+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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+
// eslint-disable-next-line default-case
41+
switch (step) {
42+
case OBJ:
43+
json = `${json}}`;
44+
break;
45+
case ARR:
46+
json = `${json}]`;
47+
break;
48+
}
49+
}
50+
51+
return json;
52+
}
53+
54+
function _fixLastStep(json: string, lastStep: JsonToken): string {
55+
switch (lastStep) {
56+
// Object cases
57+
case OBJ:
58+
return `${json}"~~":"~~"`;
59+
case OBJ_KEY:
60+
return `${json}:"~~"`;
61+
case OBJ_KEY_STR:
62+
return `${json}~~":"~~"`;
63+
case OBJ_VAL:
64+
return _maybeFixIncompleteObjValue(json);
65+
case OBJ_VAL_STR:
66+
return `${json}~~"`;
67+
case OBJ_VAL_COMPLETED:
68+
return `${json},"~~":"~~"`;
69+
70+
// Array cases
71+
case ARR:
72+
return `${json}"~~"`;
73+
case ARR_VAL:
74+
return _maybeFixIncompleteArrValue(json);
75+
case ARR_VAL_STR:
76+
return `${json}~~"`;
77+
case ARR_VAL_COMPLETED:
78+
return `${json},"~~"`;
79+
80+
default:
81+
return json;
82+
}
83+
}
84+
85+
function _maybeFixIncompleteArrValue(json: string): string {
86+
const pos = _findLastArrayDelimiter(json);
87+
88+
if (pos > -1) {
89+
const part = json.slice(pos + 1);
90+
91+
if (ALLOWED_PRIMITIVES.includes(part.trim())) {
92+
return `${json},"~~"`;
93+
}
94+
95+
// Everything else is replaced with `"~~"`
96+
return `${json.slice(0, pos + 1)}"~~"`;
97+
}
98+
99+
// fallback, this shouldn't happen, to be save
100+
return json;
101+
}
102+
103+
function _findLastArrayDelimiter(json: string): number {
104+
for (let i = json.length - 1; i >= 0; i--) {
105+
const char = json[i];
106+
107+
if (char === ',' || char === '[') {
108+
return i;
109+
}
110+
}
111+
112+
return -1;
113+
}
114+
115+
function _maybeFixIncompleteObjValue(json: string): string {
116+
const startPos = json.lastIndexOf(':');
117+
118+
const part = json.slice(startPos + 1);
119+
120+
if (ALLOWED_PRIMITIVES.includes(part.trim())) {
121+
return `${json},"~~":"~~"`;
122+
}
123+
124+
// Everything else is replaced with `"~~"`
125+
// This also means we do not have incomplete numbers, e.g `[1` is replaced with `["~~"]`
126+
return `${json.slice(0, startPos + 1)}"~~"`;
127+
}
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)