From f15994e2193153d47e658beabd73243935d022f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kry=C5=A1tof=20Wold=C5=99ich?= <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:53:11 +0100 Subject: [PATCH] fix(sdk): Transfer large envelopes (tens of MB) over bridge without crash (#2852) --- CHANGELOG.md | 7 ++ .../io/sentry/react/RNSentryModuleImpl.java | 8 +- .../java/io/sentry/react/RNSentryModule.java | 2 +- .../java/io/sentry/react/RNSentryModule.java | 2 +- ios/RNSentry.mm | 8 +- src/js/NativeRNSentry.ts | 2 +- src/js/vendor/base64-js/fromByteArray.ts | 73 +++++++++++++++++ src/js/vendor/base64-js/index.ts | 1 + src/js/vendor/index.ts | 2 + src/js/wrapper.ts | 32 +++++--- test/vendor/base64-js/big-data.test.ts | 38 +++++++++ test/vendor/base64-js/convert.test.ts | 78 +++++++++++++++++++ test/wrapper.test.ts | 62 +++++++++------ 13 files changed, 264 insertions(+), 51 deletions(-) create mode 100644 src/js/vendor/base64-js/fromByteArray.ts create mode 100644 src/js/vendor/base64-js/index.ts create mode 100644 test/vendor/base64-js/big-data.test.ts create mode 100644 test/vendor/base64-js/convert.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b635eeac..9989842b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- Encode envelopes using Base64, fix array length limit when transferring over Bridge. ([#2852](https://github.com/getsentry/sentry-react-native/pull/2852)) + - This fix requires a rebuild of the native app + ## 5.14.1 ### Fixes diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 1da6d0951..c33750512 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -71,6 +71,7 @@ import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; import io.sentry.util.JsonSerializationUtils; +import io.sentry.vendor.Base64; public class RNSentryModuleImpl { @@ -339,11 +340,8 @@ public void fetchNativeFrames(Promise promise) { } } - public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise promise) { - byte[] bytes = new byte[rawBytes.size()]; - for (int i = 0; i < rawBytes.size(); i++) { - bytes[i] = (byte) rawBytes.getInt(i); - } + public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { + byte[] bytes = Base64.decode(rawBytes, Base64.DEFAULT); try { InternalSentrySdk.captureEnvelope(bytes); diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 156d29575..9f82f6894 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -55,7 +55,7 @@ public void fetchNativeFrames(Promise promise) { } @Override - public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise promise) { + public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { this.impl.captureEnvelope(rawBytes, options, promise); } diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 85b03b62a..2d39e8e05 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -54,7 +54,7 @@ public void fetchNativeFrames(Promise promise) { } @ReactMethod - public void captureEnvelope(ReadableArray rawBytes, ReadableMap options, Promise promise) { + public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { this.impl.captureEnvelope(rawBytes, options, promise); } diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index f095311e6..f205b01bf 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -396,16 +396,12 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray*)instructionsAdd }); } -RCT_EXPORT_METHOD(captureEnvelope:(NSArray * _Nonnull)bytes +RCT_EXPORT_METHOD(captureEnvelope:(NSString * _Nonnull)rawBytes options: (NSDictionary * _Nonnull)options resolve:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSMutableData *data = [[NSMutableData alloc] initWithCapacity: [bytes count]]; - for(NSNumber *number in bytes) { - char byte = [number charValue]; - [data appendBytes: &byte length: 1]; - } + NSData *data = [[NSData alloc] initWithBase64EncodedString:rawBytes options:0]; SentryEnvelope *envelope = [PrivateSentrySDKOnly envelopeWithData:data]; if (envelope == nil) { diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index 2cec0331c..5f8031195 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -8,7 +8,7 @@ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; export interface Spec extends TurboModule { addBreadcrumb(breadcrumb: UnsafeObject): void; captureEnvelope( - bytes: number[], + bytes: string, options: { store: boolean; }, diff --git a/src/js/vendor/base64-js/fromByteArray.ts b/src/js/vendor/base64-js/fromByteArray.ts new file mode 100644 index 000000000..51c046b0a --- /dev/null +++ b/src/js/vendor/base64-js/fromByteArray.ts @@ -0,0 +1,73 @@ +/* eslint-disable */ + +// The MIT License (MIT) + +// Copyright (c) 2014 Jameson Little + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Adapted from https://github.dev/beatgammit/base64-js/blob/88957c9943c7e2a0f03cdf73e71d579e433627d3/index.js#L119 + +const lookup: string[] = []; + +const code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +for (let i = 0, len = code.length; i < len; ++i) { + lookup[i] = code[i]; +} + +function tripletToBase64(num: number): string { + return lookup[(num >> 18) & 0x3f] + lookup[(num >> 12) & 0x3f] + lookup[(num >> 6) & 0x3f] + lookup[num & 0x3f]; +} + +function encodeChunk(uint8: Uint8Array | number[], start: number, end: number): string { + let tmp; + const output = []; + for (let i = start; i < end; i += 3) { + tmp = ((uint8[i] << 16) & 0xff0000) + ((uint8[i + 1] << 8) & 0xff00) + (uint8[i + 2] & 0xff); + output.push(tripletToBase64(tmp)); + } + return output.join(''); +} + +/** + * Converts a Uint8Array or Array of bytes into a string representation of base64. + */ +export function base64StringFromByteArray(uint8: Uint8Array | number[]): string { + let tmp; + const len = uint8.length; + const extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes + const parts = []; + const maxChunkLength = 16383; // must be multiple of 3 + + // go through the array every three bytes, we'll deal with trailing stuff later + for (let i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) { + parts.push(encodeChunk(uint8, i, i + maxChunkLength > len2 ? len2 : i + maxChunkLength)); + } + + // pad the end with zeros, but make sure to not forget the extra bytes + if (extraBytes === 1) { + tmp = uint8[len - 1]; + parts.push(`${lookup[tmp >> 2] + lookup[(tmp << 4) & 0x3f]}==`); + } else if (extraBytes === 2) { + tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + parts.push(`${lookup[tmp >> 10] + lookup[(tmp >> 4) & 0x3f] + lookup[(tmp << 2) & 0x3f]}=`); + } + + return parts.join(''); +} diff --git a/src/js/vendor/base64-js/index.ts b/src/js/vendor/base64-js/index.ts new file mode 100644 index 000000000..14788724b --- /dev/null +++ b/src/js/vendor/base64-js/index.ts @@ -0,0 +1 @@ +export { base64StringFromByteArray } from './fromByteArray'; diff --git a/src/js/vendor/index.ts b/src/js/vendor/index.ts index 80d5ec405..0f9ae3540 100644 --- a/src/js/vendor/index.ts +++ b/src/js/vendor/index.ts @@ -1 +1,3 @@ export { utf8ToBytes } from './buffer'; + +export { base64StringFromByteArray } from './base64-js'; diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 6d0b4f857..b108a84eb 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -27,7 +27,7 @@ import type * as Hermes from './profiling/hermes'; import type { NativeProfileEvent } from './profiling/nativeTypes'; import type { RequiredKeysUser } from './user'; import { isTurboModuleEnabled } from './utils/environment'; -import { utf8ToBytes } from './vendor'; +import { base64StringFromByteArray, utf8ToBytes } from './vendor'; const RNSentry: Spec | undefined = isTurboModuleEnabled() ? TurboModuleRegistry.get('RNSentry') @@ -93,6 +93,8 @@ interface SentryNativeWrapper { fetchNativeStackFramesBy(instructionsAddr: number[]): Promise; } +const EOL = utf8ToBytes('\n'); + /** * Our internal interface for calling native functions */ @@ -125,27 +127,27 @@ export const NATIVE: SentryNativeWrapper = { throw this._NativeClientError; } - const [EOL] = utf8ToBytes('\n'); - const [envelopeHeader, envelopeItems] = envelope; const headerString = JSON.stringify(envelopeHeader); - let envelopeBytes: number[] = utf8ToBytes(headerString); - envelopeBytes.push(EOL); + const headerBytes = utf8ToBytes(headerString); + let envelopeBytes: Uint8Array = new Uint8Array(headerBytes.length + EOL.length); + envelopeBytes.set(headerBytes); + envelopeBytes.set(EOL, headerBytes.length); let hardCrashed: boolean = false; for (const rawItem of envelopeItems) { const [itemHeader, itemPayload] = this._processItem(rawItem); let bytesContentType: string; - let bytesPayload: number[] = []; + let bytesPayload: number[] | Uint8Array | undefined; if (typeof itemPayload === 'string') { bytesContentType = 'text/plain'; bytesPayload = utf8ToBytes(itemPayload); } else if (itemPayload instanceof Uint8Array) { bytesContentType = typeof itemHeader.content_type === 'string' ? itemHeader.content_type : 'application/octet-stream'; - bytesPayload = [...itemPayload]; + bytesPayload = itemPayload; } else { bytesContentType = 'application/json'; bytesPayload = utf8ToBytes(JSON.stringify(itemPayload)); @@ -159,13 +161,19 @@ export const NATIVE: SentryNativeWrapper = { (itemHeader as BaseEnvelopeItemHeaders).length = bytesPayload.length; const serializedItemHeader = JSON.stringify(itemHeader); - envelopeBytes.push(...utf8ToBytes(serializedItemHeader)); - envelopeBytes.push(EOL); - envelopeBytes = envelopeBytes.concat(bytesPayload); - envelopeBytes.push(EOL); + const bytesItemHeader = utf8ToBytes(serializedItemHeader); + const newBytes = new Uint8Array( + envelopeBytes.length + bytesItemHeader.length + EOL.length + bytesPayload.length + EOL.length, + ); + newBytes.set(envelopeBytes); + newBytes.set(bytesItemHeader, envelopeBytes.length); + newBytes.set(EOL, envelopeBytes.length + bytesItemHeader.length); + newBytes.set(bytesPayload, envelopeBytes.length + bytesItemHeader.length + EOL.length); + newBytes.set(EOL, envelopeBytes.length + bytesItemHeader.length + EOL.length + bytesPayload.length); + envelopeBytes = newBytes; } - await RNSentry.captureEnvelope(envelopeBytes, { store: hardCrashed }); + await RNSentry.captureEnvelope(base64StringFromByteArray(envelopeBytes), { store: hardCrashed }); }, /** diff --git a/test/vendor/base64-js/big-data.test.ts b/test/vendor/base64-js/big-data.test.ts new file mode 100644 index 000000000..750336b0d --- /dev/null +++ b/test/vendor/base64-js/big-data.test.ts @@ -0,0 +1,38 @@ +// The MIT License (MIT) + +// Copyright (c) 2014 Jameson Little + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Adapted from https://github.dev/beatgammit/base64-js/blob/88957c9943c7e2a0f03cdf73e71d579e433627d3/test/big-data.js#L4 + +import { base64StringFromByteArray } from '../../../src/js/vendor'; + +describe('base64-js', () => { + test('convert big data to base64', () => { + const SIZE_2MB = 2e6; // scaled down from original 64MiB + const big = new Uint8Array(SIZE_2MB); + for (let i = 0, length = big.length; i < length; ++i) { + big[i] = i % 256; + } + const b64str = base64StringFromByteArray(big); + const arr = Uint8Array.from(Buffer.from(b64str, 'base64')); + expect(arr).toEqual(big); + }); +}); diff --git a/test/vendor/base64-js/convert.test.ts b/test/vendor/base64-js/convert.test.ts new file mode 100644 index 000000000..d8aa7527b --- /dev/null +++ b/test/vendor/base64-js/convert.test.ts @@ -0,0 +1,78 @@ +// The MIT License (MIT) + +// Copyright (c) 2014 Jameson Little + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Adapted from https://github.dev/beatgammit/base64-js/blob/88957c9943c7e2a0f03cdf73e71d579e433627d3/test/convert.js#L15 + +import { base64StringFromByteArray } from '../../../src/js/vendor'; + +describe('base64-js', () => { + const checks = ['a', 'aa', 'aaa', 'hi', 'hi!', 'hi!!', 'sup', 'sup?', 'sup?!']; + + test('convert to base64 and back', () => { + for (const check of checks) { + const b64Str = base64StringFromByteArray( + map(check, function (char: string) { + return char.charCodeAt(0); + }), + ); + + const str = Buffer.from(b64Str, 'base64').toString(); + + expect(check).toEqual(str); + } + }); + + const data: [number[], string][] = [ + [[0, 0, 0], 'AAAA'], + [[0, 0, 1], 'AAAB'], + [[0, 1, -1], 'AAH/'], + [[1, 1, 1], 'AQEB'], + [[0, -73, 23], 'ALcX'], + ]; + + test('convert known data to string', () => { + for (const check of data) { + const bytes = check[0]; + const expected = check[1]; + const actual = base64StringFromByteArray(bytes); + expect(actual).toEqual(expected); + } + }); + + function map(arr: string, callback: (char: string) => number): number[] { + const res = []; + let kValue, mappedValue; + + for (let k = 0, len = arr.length; k < len; k++) { + if (typeof arr === 'string' && !!arr.charAt(k)) { + kValue = arr.charAt(k); + mappedValue = callback(kValue); + res[k] = mappedValue; + } else if (typeof arr !== 'string' && k in arr) { + kValue = arr[k]; + mappedValue = callback(kValue); + res[k] = mappedValue; + } + } + return res; + } +}); diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts index 31964091b..40ef52d4a 100644 --- a/test/wrapper.test.ts +++ b/test/wrapper.test.ts @@ -4,7 +4,7 @@ import * as RN from 'react-native'; import type { Spec } from '../src/js/NativeRNSentry'; import type { ReactNativeOptions } from '../src/js/options'; -import { utf8ToBytes } from '../src/js/vendor'; +import { base64StringFromByteArray, utf8ToBytes } from '../src/js/vendor'; import { NATIVE } from '../src/js/wrapper'; jest.mock('react-native', () => { @@ -269,10 +269,12 @@ describe('Tests Native Wrapper', () => { await NATIVE.sendEnvelope(env); expect(RNSentry.captureEnvelope).toBeCalledWith( - utf8ToBytes( - '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":87}\n' + - '{"event_id":"event0","message":"test","sdk":{"name":"test-sdk-name","version":"2.1.3"}}\n', + base64StringFromByteArray( + utf8ToBytes( + '{"event_id":"event0","sent_at":"123"}\n' + + '{"type":"event","content_type":"application/json","length":87}\n' + + '{"event_id":"event0","message":"test","sdk":{"name":"test-sdk-name","version":"2.1.3"}}\n', + ), ), { store: false }, ); @@ -299,10 +301,12 @@ describe('Tests Native Wrapper', () => { await NATIVE.sendEnvelope(env); expect(RNSentry.captureEnvelope).toBeCalledWith( - utf8ToBytes( - '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":93}\n' + - '{"event_id":"event0","sdk":{"name":"test-sdk-name","version":"2.1.3"},"instance":{"value":0}}\n', + base64StringFromByteArray( + utf8ToBytes( + '{"event_id":"event0","sent_at":"123"}\n' + + '{"type":"event","content_type":"application/json","length":93}\n' + + '{"event_id":"event0","sdk":{"name":"test-sdk-name","version":"2.1.3"},"instance":{"value":0}}\n', + ), ), { store: false }, ); @@ -334,10 +338,12 @@ describe('Tests Native Wrapper', () => { await NATIVE.sendEnvelope(env); expect(RNSentry.captureEnvelope).toBeCalledWith( - utf8ToBytes( - '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":50}\n' + - '{"event_id":"event0","message":{"message":"test"}}\n', + base64StringFromByteArray( + utf8ToBytes( + '{"event_id":"event0","sent_at":"123"}\n' + + '{"type":"event","content_type":"application/json","length":50}\n' + + '{"event_id":"event0","message":{"message":"test"}}\n', + ), ), { store: false }, ); @@ -371,10 +377,12 @@ describe('Tests Native Wrapper', () => { await NATIVE.sendEnvelope(env); expect(RNSentry.captureEnvelope).toBeCalledWith( - utf8ToBytes( - '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":124}\n' + - '{"event_id":"event0","exception":{"values":[{"mechanism":{"handled":true,"type":""}}]},"breadcrumbs":[{"message":"crumb!"}]}\n', + base64StringFromByteArray( + utf8ToBytes( + '{"event_id":"event0","sent_at":"123"}\n' + + '{"type":"event","content_type":"application/json","length":124}\n' + + '{"event_id":"event0","exception":{"values":[{"mechanism":{"handled":true,"type":""}}]},"breadcrumbs":[{"message":"crumb!"}]}\n', + ), ), { store: false }, ); @@ -398,10 +406,12 @@ describe('Tests Native Wrapper', () => { await NATIVE.sendEnvelope(env); expect(RNSentry.captureEnvelope).toBeCalledWith( - utf8ToBytes( - '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":58}\n' + - '{"event_id":"event0","breadcrumbs":[{"message":"crumb!"}]}\n', + base64StringFromByteArray( + utf8ToBytes( + '{"event_id":"event0","sent_at":"123"}\n' + + '{"type":"event","content_type":"application/json","length":58}\n' + + '{"event_id":"event0","breadcrumbs":[{"message":"crumb!"}]}\n', + ), ), { store: false }, ); @@ -435,10 +445,12 @@ describe('Tests Native Wrapper', () => { await NATIVE.sendEnvelope(env); expect(RNSentry.captureEnvelope).toBeCalledWith( - utf8ToBytes( - '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":125}\n' + - '{"event_id":"event0","exception":{"values":[{"mechanism":{"handled":false,"type":""}}]},"breadcrumbs":[{"message":"crumb!"}]}\n', + base64StringFromByteArray( + utf8ToBytes( + '{"event_id":"event0","sent_at":"123"}\n' + + '{"type":"event","content_type":"application/json","length":125}\n' + + '{"event_id":"event0","exception":{"values":[{"mechanism":{"handled":false,"type":""}}]},"breadcrumbs":[{"message":"crumb!"}]}\n', + ), ), { store: true }, );