sections.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import type {MouseEvent} from 'react';
  2. import {useEffect, useMemo} from 'react';
  3. import queryString from 'query-string';
  4. import {Flex} from 'sentry/components/container/flex';
  5. import QuestionTooltip from 'sentry/components/questionTooltip';
  6. import {useReplayContext} from 'sentry/components/replays/replayContext';
  7. import StructuredEventData from 'sentry/components/structuredEventData';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10';
  11. import type {
  12. NetworkMetaWarning,
  13. ReplayNetworkRequestOrResponse,
  14. } from 'sentry/utils/replays/replay';
  15. import {
  16. getFrameMethod,
  17. getFrameStatus,
  18. getReqRespContentTypes,
  19. isRequestFrame,
  20. } from 'sentry/utils/replays/resourceFrame';
  21. import type {SpanFrame} from 'sentry/utils/replays/types';
  22. import type {KeyValueTuple} from 'sentry/views/replays/detail/network/details/components';
  23. import {
  24. Indent,
  25. InspectorMargin,
  26. keyValueTableOrNotFound,
  27. SectionItem,
  28. SizeTooltip,
  29. Warning,
  30. } from 'sentry/views/replays/detail/network/details/components';
  31. import {useDismissReqRespBodiesAlert} from 'sentry/views/replays/detail/network/details/onboarding';
  32. import {fixJson} from 'sentry/views/replays/detail/network/truncateJson/fixJson';
  33. import TimestampButton from 'sentry/views/replays/detail/timestampButton';
  34. export type SectionProps = {
  35. item: SpanFrame;
  36. projectId: string;
  37. startTimestampMs: number;
  38. };
  39. const config = {
  40. isString: (v: any) => {
  41. return typeof v === 'string';
  42. },
  43. };
  44. const UNKNOWN_STATUS = 'unknown';
  45. export function GeneralSection({item, startTimestampMs}: SectionProps) {
  46. const {setCurrentTime} = useReplayContext();
  47. const requestFrame = isRequestFrame(item) ? item : null;
  48. const data: KeyValueTuple[] = [
  49. {key: t('URL'), value: item.description},
  50. {key: t('Type'), value: item.op},
  51. {key: t('Method'), value: getFrameMethod(item)},
  52. {key: t('Status Code'), value: String(getFrameStatus(item) ?? UNKNOWN_STATUS)},
  53. {
  54. key: t('Request Body Size'),
  55. value: (
  56. <SizeTooltip>
  57. {formatBytesBase10(
  58. requestFrame?.data?.request?.size ?? requestFrame?.data?.requestBodySize ?? 0
  59. )}
  60. </SizeTooltip>
  61. ),
  62. },
  63. {
  64. key: t('Response Body Size'),
  65. value: (
  66. <SizeTooltip>
  67. {formatBytesBase10(
  68. requestFrame?.data?.response?.size ??
  69. requestFrame?.data?.responseBodySize ??
  70. 0
  71. )}
  72. </SizeTooltip>
  73. ),
  74. },
  75. {
  76. key: t('Duration'),
  77. value: `${(item.endTimestampMs - item.timestampMs).toFixed(2)}ms`,
  78. },
  79. {
  80. key: t('Timestamp'),
  81. value: (
  82. <TimestampButton
  83. format="mm:ss.SSS"
  84. onClick={(event: MouseEvent) => {
  85. event.stopPropagation();
  86. setCurrentTime(item.offsetMs);
  87. }}
  88. startTimestampMs={startTimestampMs}
  89. timestampMs={item.timestampMs}
  90. />
  91. ),
  92. },
  93. ];
  94. return (
  95. <SectionItem title={t('General')}>
  96. {keyValueTableOrNotFound(data, t('Missing request details'))}
  97. </SectionItem>
  98. );
  99. }
  100. export function RequestHeadersSection({item}: SectionProps) {
  101. const contentTypeHeaders = getReqRespContentTypes(item);
  102. const isContentTypeMismatched =
  103. contentTypeHeaders.req !== undefined &&
  104. contentTypeHeaders.resp !== undefined &&
  105. contentTypeHeaders.req !== contentTypeHeaders.resp;
  106. const data = isRequestFrame(item) ? item.data : {};
  107. const headers: KeyValueTuple[] = Object.entries(data.request?.headers || {}).map(
  108. ([key, value]) => {
  109. const warn = key === 'content-type' && isContentTypeMismatched;
  110. return {
  111. key,
  112. value: warn ? (
  113. <Flex align="center" gap={space(0.5)}>
  114. {value}
  115. <QuestionTooltip
  116. size="xs"
  117. title={t('The content-type of the request does not match the response.')}
  118. />
  119. </Flex>
  120. ) : (
  121. value
  122. ),
  123. type: warn ? 'warning' : undefined,
  124. };
  125. }
  126. );
  127. return (
  128. <SectionItem title={t('Request Headers')}>
  129. {keyValueTableOrNotFound(headers, t('Headers not captured'))}
  130. </SectionItem>
  131. );
  132. }
  133. export function ResponseHeadersSection({item}: SectionProps) {
  134. const contentTypeHeaders = getReqRespContentTypes(item);
  135. const isContentTypeMismatched =
  136. contentTypeHeaders.req !== undefined &&
  137. contentTypeHeaders.resp !== undefined &&
  138. contentTypeHeaders.req !== contentTypeHeaders.resp;
  139. const data = isRequestFrame(item) ? item.data : {};
  140. const headers: KeyValueTuple[] = Object.entries(data.response?.headers || {}).map(
  141. ([key, value]) => {
  142. const warn = key === 'content-type' && isContentTypeMismatched;
  143. return {
  144. key,
  145. value: warn ? (
  146. <Flex align="center" gap={space(0.5)}>
  147. {value}
  148. <QuestionTooltip
  149. size="xs"
  150. title={t('The content-type of the request does not match the response.')}
  151. />
  152. </Flex>
  153. ) : (
  154. value
  155. ),
  156. type: warn ? 'warning' : undefined,
  157. tooltip: undefined,
  158. };
  159. }
  160. );
  161. return (
  162. <SectionItem title={t('Response Headers')}>
  163. {keyValueTableOrNotFound(headers, t('Headers not captured'))}
  164. </SectionItem>
  165. );
  166. }
  167. export function QueryParamsSection({item}: SectionProps) {
  168. const queryParams = queryString.parse(item.description?.split('?')?.[1] ?? '');
  169. return (
  170. <SectionItem title={t('Query String Parameters')}>
  171. <Indent>
  172. <StructuredEventData
  173. data={queryParams}
  174. showCopyButton
  175. forceDefaultExpand
  176. maxDefaultDepth={3}
  177. config={config}
  178. />
  179. </Indent>
  180. </SectionItem>
  181. );
  182. }
  183. export function RequestPayloadSection({item}: SectionProps) {
  184. const {dismiss, isDismissed} = useDismissReqRespBodiesAlert();
  185. const data = useMemo(() => (isRequestFrame(item) ? item.data : {}), [item]);
  186. const {warnings, body} = getBodyAndWarnings(data.request);
  187. useEffect(() => {
  188. if (!isDismissed && 'request' in data) {
  189. dismiss();
  190. }
  191. }, [dismiss, data, isDismissed]);
  192. return (
  193. <SectionItem
  194. title={t('Request Body')}
  195. titleExtra={
  196. <SizeTooltip>
  197. {t('Size:')} {formatBytesBase10(data.request?.size ?? 0)}
  198. </SizeTooltip>
  199. }
  200. >
  201. <Indent>
  202. <Warning warnings={warnings} />
  203. {'request' in data ? (
  204. <StructuredEventData
  205. data={body}
  206. forceDefaultExpand
  207. maxDefaultDepth={2}
  208. showCopyButton
  209. config={config}
  210. />
  211. ) : (
  212. t('Request body not found.')
  213. )}
  214. </Indent>
  215. </SectionItem>
  216. );
  217. }
  218. export function ResponsePayloadSection({item}: SectionProps) {
  219. const {dismiss, isDismissed} = useDismissReqRespBodiesAlert();
  220. const data = useMemo(() => (isRequestFrame(item) ? item.data : {}), [item]);
  221. const {warnings, body} = getBodyAndWarnings(data.response);
  222. useEffect(() => {
  223. if (!isDismissed && 'response' in data) {
  224. dismiss();
  225. }
  226. }, [dismiss, data, isDismissed]);
  227. return (
  228. <SectionItem
  229. title={t('Response Body')}
  230. titleExtra={
  231. <SizeTooltip>
  232. {t('Size:')} {formatBytesBase10(data.response?.size ?? 0)}
  233. </SizeTooltip>
  234. }
  235. >
  236. <InspectorMargin>
  237. <Warning warnings={warnings} />
  238. {'response' in data ? (
  239. <StructuredEventData
  240. data={body}
  241. forceDefaultExpand
  242. maxDefaultDepth={2}
  243. showCopyButton
  244. config={config}
  245. />
  246. ) : (
  247. t('Response body not found.')
  248. )}
  249. </InspectorMargin>
  250. </SectionItem>
  251. );
  252. }
  253. function getBodyAndWarnings(reqOrRes?: ReplayNetworkRequestOrResponse): {
  254. body: ReplayNetworkRequestOrResponse['body'];
  255. warnings: NetworkMetaWarning[];
  256. } {
  257. if (!reqOrRes) {
  258. return {body: undefined, warnings: []};
  259. }
  260. const warnings = reqOrRes._meta?.warnings ?? [];
  261. let body = reqOrRes.body;
  262. if (typeof body === 'string' && warnings.includes('MAYBE_JSON_TRUNCATED')) {
  263. try {
  264. const json = fixJson(body);
  265. body = JSON.parse(json);
  266. warnings.push('JSON_TRUNCATED');
  267. } catch {
  268. // this can fail, in which case we just use the body string
  269. warnings.push('INVALID_JSON');
  270. warnings.push('TEXT_TRUNCATED');
  271. }
  272. }
  273. return {body, warnings};
  274. }