Skip to content

Commit

Permalink
feat(replay): Fix truncated JSON request/response bodies (#59266)
Browse files Browse the repository at this point in the history
This implements the changes from the SDK here:
getsentry/sentry-javascript#9437

If it encounters a request/response body with a `MAYBE_TRUNCATED_JSON`
warning, it will try to auto-fix it with the same logic as we did in the
SDK.
  • Loading branch information
mydea committed Nov 7, 2023
1 parent f82fc06 commit 6480e22
Show file tree
Hide file tree
Showing 8 changed files with 601 additions and 10 deletions.
1 change: 1 addition & 0 deletions static/app/utils/replays/replay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ type JsonObject = Record<string, unknown>;
type JsonArray = unknown[];

export type NetworkMetaWarning =
| 'MAYBE_JSON_TRUNCATED'
| 'JSON_TRUNCATED'
| 'TEXT_TRUNCATED'
| 'INVALID_JSON'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ const WarningText = styled('span')`
color: ${p => p.theme.errorText};
`;

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

if (warnings?.includes('INVALID_JSON')) {
if (warnings.includes('INVALID_JSON')) {
return <WarningText>{t('Invalid JSON')}</WarningText>;
}

Expand Down
46 changes: 39 additions & 7 deletions static/app/views/replays/detail/network/details/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {useReplayContext} from 'sentry/components/replays/replayContext';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {formatBytesBase10} from 'sentry/utils';
import {
NetworkMetaWarning,
ReplayNetworkRequestOrResponse,
} from 'sentry/utils/replays/replay';
import {
getFrameMethod,
getFrameStatus,
Expand All @@ -24,6 +28,7 @@ import {
Warning,
} from 'sentry/views/replays/detail/network/details/components';
import {useDismissReqRespBodiesAlert} from 'sentry/views/replays/detail/network/details/onboarding';
import {fixJson} from 'sentry/views/replays/detail/network/truncateJson/fixJson';
import TimestampButton from 'sentry/views/replays/detail/timestampButton';

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

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

// TODO[replay]: what about:
// `requestFrame?.data?.request?.size` vs. `requestFrame?.data?.requestBodySize`

const data: KeyValueTuple[] = [
{key: t('URL'), value: item.description},
{key: t('Type'), value: item.op},
Expand Down Expand Up @@ -179,6 +181,8 @@ export function RequestPayloadSection({item}: SectionProps) {
const {dismiss, isDismissed} = useDismissReqRespBodiesAlert();

const data = useMemo(() => (isRequestFrame(item) ? item.data : {}), [item]);
const {warnings, body} = getBodyAndWarnings(data.request);

useEffect(() => {
if (!isDismissed && 'request' in data) {
dismiss();
Expand All @@ -195,9 +199,9 @@ export function RequestPayloadSection({item}: SectionProps) {
}
>
<Indent>
<Warning warnings={data.request?._meta?.warnings} />
<Warning warnings={warnings} />
{'request' in data ? (
<ObjectInspector data={data.request?.body} expandLevel={2} showCopyButton />
<ObjectInspector data={body} expandLevel={2} showCopyButton />
) : (
t('Request body not found.')
)}
Expand All @@ -210,6 +214,8 @@ export function ResponsePayloadSection({item}: SectionProps) {
const {dismiss, isDismissed} = useDismissReqRespBodiesAlert();

const data = useMemo(() => (isRequestFrame(item) ? item.data : {}), [item]);
const {warnings, body} = getBodyAndWarnings(data.response);

useEffect(() => {
if (!isDismissed && 'response' in data) {
dismiss();
Expand All @@ -226,13 +232,39 @@ export function ResponsePayloadSection({item}: SectionProps) {
}
>
<Indent>
<Warning warnings={data?.response?._meta?.warnings} />
<Warning warnings={warnings} />
{'response' in data ? (
<ObjectInspector data={data.response?.body} expandLevel={2} showCopyButton />
<ObjectInspector data={body} expandLevel={2} showCopyButton />
) : (
t('Response body not found.')
)}
</Indent>
</SectionItem>
);
}

function getBodyAndWarnings(reqOrRes?: ReplayNetworkRequestOrResponse): {
body: ReplayNetworkRequestOrResponse['body'];
warnings: NetworkMetaWarning[];
} {
if (!reqOrRes) {
return {body: undefined, warnings: []};
}

const warnings = reqOrRes._meta?.warnings ?? [];
let body = reqOrRes.body;

if (typeof body === 'string' && warnings.includes('MAYBE_JSON_TRUNCATED')) {
try {
const json = fixJson(body);
body = JSON.parse(json);
warnings.push('JSON_TRUNCATED');
} catch {
// this can fail, in which case we just use the body string
warnings.push('INVALID_JSON');
warnings.push('TEXT_TRUNCATED');
}
}

return {body, warnings};
}
127 changes: 127 additions & 0 deletions static/app/views/replays/detail/network/truncateJson/completeJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type {JsonToken} from './constants';
import {
ARR,
ARR_VAL,
ARR_VAL_COMPLETED,
ARR_VAL_STR,
OBJ,
OBJ_KEY,
OBJ_KEY_STR,
OBJ_VAL,
OBJ_VAL_COMPLETED,
OBJ_VAL_STR,
} from './constants';

const ALLOWED_PRIMITIVES = ['true', 'false', 'null'];

/**
* Complete an incomplete JSON string.
* This will ensure that the last element always has a `"~~"` to indicate it was truncated.
* For example, `[1,2,` will be completed to `[1,2,"~~"]`
* and `{"aa":"b` will be completed to `{"aa":"b~~"}`
*/
export function completeJson(incompleteJson: string, stack: JsonToken[]): string {
if (!stack.length) {
return incompleteJson;
}

let json = incompleteJson;

// Most checks are only needed for the last step in the stack
const lastPos = stack.length - 1;
const lastStep = stack[lastPos];

json = _fixLastStep(json, lastStep);

// Complete remaining steps - just add closing brackets
for (let i = lastPos; i >= 0; i--) {
const step = stack[i];

// eslint-disable-next-line default-case
switch (step) {
case OBJ:
json = `${json}}`;
break;
case ARR:
json = `${json}]`;
break;
}
}

return json;
}

function _fixLastStep(json: string, lastStep: JsonToken): string {
switch (lastStep) {
// Object cases
case OBJ:
return `${json}"~~":"~~"`;
case OBJ_KEY:
return `${json}:"~~"`;
case OBJ_KEY_STR:
return `${json}~~":"~~"`;
case OBJ_VAL:
return _maybeFixIncompleteObjValue(json);
case OBJ_VAL_STR:
return `${json}~~"`;
case OBJ_VAL_COMPLETED:
return `${json},"~~":"~~"`;

// Array cases
case ARR:
return `${json}"~~"`;
case ARR_VAL:
return _maybeFixIncompleteArrValue(json);
case ARR_VAL_STR:
return `${json}~~"`;
case ARR_VAL_COMPLETED:
return `${json},"~~"`;

default:
return json;
}
}

function _maybeFixIncompleteArrValue(json: string): string {
const pos = _findLastArrayDelimiter(json);

if (pos > -1) {
const part = json.slice(pos + 1);

if (ALLOWED_PRIMITIVES.includes(part.trim())) {
return `${json},"~~"`;
}

// Everything else is replaced with `"~~"`
return `${json.slice(0, pos + 1)}"~~"`;
}

// fallback, this shouldn't happen, to be save
return json;
}

function _findLastArrayDelimiter(json: string): number {
for (let i = json.length - 1; i >= 0; i--) {
const char = json[i];

if (char === ',' || char === '[') {
return i;
}
}

return -1;
}

function _maybeFixIncompleteObjValue(json: string): string {
const startPos = json.lastIndexOf(':');

const part = json.slice(startPos + 1);

if (ALLOWED_PRIMITIVES.includes(part.trim())) {
return `${json},"~~":"~~"`;
}

// Everything else is replaced with `"~~"`
// This also means we do not have incomplete numbers, e.g `[1` is replaced with `["~~"]`
return `${json.slice(0, startPos + 1)}"~~"`;
}
23 changes: 23 additions & 0 deletions static/app/views/replays/detail/network/truncateJson/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const OBJ = 10;
export const OBJ_KEY = 11;
export const OBJ_KEY_STR = 12;
export const OBJ_VAL = 13;
export const OBJ_VAL_STR = 14;
export const OBJ_VAL_COMPLETED = 15;

export const ARR = 20;
export const ARR_VAL = 21;
export const ARR_VAL_STR = 22;
export const ARR_VAL_COMPLETED = 23;

export type JsonToken =
| typeof OBJ
| typeof OBJ_KEY
| typeof OBJ_KEY_STR
| typeof OBJ_VAL
| typeof OBJ_VAL_STR
| typeof OBJ_VAL_COMPLETED
| typeof ARR
| typeof ARR_VAL
| typeof ARR_VAL_STR
| typeof ARR_VAL_COMPLETED;

0 comments on commit 6480e22

Please sign in to comment.