diff --git a/CHANGELOG.md b/CHANGELOG.md index 2784d098..59dcdf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,11 @@ Sentry.init({ ### Fixes +- Added missing integrations `inboundFiltersIntegration`, `functionToStringIntegration`, `browserApiErrorsIntegration`, `breadcrumbsIntegration`, `globalHandlersIntegration`, `linkedErrorsIntegration`, `dedupeIntegration` and `browserSessionIntegration` ([#1047](https://github.com/getsentry/sentry-capacitor/pull/1047)) + - This fixes the following option parameters that weren't working: `ignoreErrors`, `ignoreTransactions`, `allowUrls`, `denyUrls` + - For more information about the Integrations, check the following link: https://docs.sentry.io/platforms/javascript/configuration/integrations. + +- Breadcrumbs are now showing and are tied with native breadcrumbs too ([#1047](https://github.com/getsentry/sentry-capacitor/pull/1047)) - Init now showing the correct JSDoc for Vue/Nuxt init parameters. ([#1046](https://github.com/getsentry/sentry-capacitor/pull/1046)) - Replays/Logs/Sessions now have the `capacitor` SDK name as the source of the event. ([#1043](https://github.com/getsentry/sentry-capacitor/pull/1043)) - Sentry Capacitor integrations are now exposed to `@sentry/capacitor` ([#1039](https://github.com/getsentry/sentry-capacitor/pull/1039)) diff --git a/android/src/main/java/io/sentry/capacitor/CapSentryMapConverter.java b/android/src/main/java/io/sentry/capacitor/CapSentryMapConverter.java new file mode 100644 index 00000000..18fd9b27 --- /dev/null +++ b/android/src/main/java/io/sentry/capacitor/CapSentryMapConverter.java @@ -0,0 +1,140 @@ +package io.sentry.capacitor; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import com.getcapacitor.JSObject; +import org.json.JSONArray; +import org.json.JSONObject; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; + +public final class CapSentryMapConverter { + public static final String NAME = "CapSentry.MapConverter"; + private static final ILogger logger = new AndroidLogger(NAME); + + private CapSentryMapConverter() { + throw new AssertionError("Utility class should not be instantiated"); + } + + public static Object convertToWritable(Object serialized) { + if (serialized instanceof List) { + JSONArray writable = new JSONArray(); + for (Object item : (List) serialized) { + addValueToWritableArray(writable, convertToWritable(item)); + } + return writable; + } else if (serialized instanceof Map) { + JSObject writable = new JSObject(); + for (Map.Entry entry : ((Map) serialized).entrySet()) { + Object key = entry.getKey(); + Object value = entry.getValue(); + + if (key instanceof String) { + addValueToWritableMap(writable, (String) key, convertToWritable(value)); + } else { + logger.log(SentryLevel.ERROR, "Only String keys are supported in Map.", key); + } + } + return writable; + } else if (serialized instanceof Byte) { + return Integer.valueOf((Byte) serialized); + } else if (serialized instanceof Short) { + return Integer.valueOf((Short) serialized); + } else if (serialized instanceof Float) { + return Double.valueOf((Float) serialized); + } else if (serialized instanceof Long) { + return Double.valueOf((Long) serialized); + } else if (serialized instanceof BigInteger) { + return ((BigInteger) serialized).doubleValue(); + } else if (serialized instanceof BigDecimal) { + return ((BigDecimal) serialized).doubleValue(); + } else if (serialized instanceof Integer + || serialized instanceof Double + || serialized instanceof Boolean + || serialized == null + || serialized instanceof String) { + return serialized; + } else { + logger.log( + SentryLevel.ERROR, "Supplied serialized value could not be converted." + serialized); + return null; + } + } + + private static void addValueToWritableArray(JSONArray writableArray, Object value) { + try { + if (value == null) { + writableArray.put(JSONObject.NULL); + } else if (value instanceof Boolean) { + writableArray.put((Boolean) value); + } else if (value instanceof Double) { + writableArray.put((Double) value); + } else if (value instanceof Float) { + writableArray.put(((Float) value).doubleValue()); + } else if (value instanceof Integer) { + writableArray.put((Integer) value); + } else if (value instanceof Short) { + writableArray.put(((Short) value).intValue()); + } else if (value instanceof Byte) { + writableArray.put(((Byte) value).intValue()); + } else if (value instanceof Long) { + writableArray.put(((Long) value).doubleValue()); + } else if (value instanceof BigInteger) { + writableArray.put(((BigInteger) value).doubleValue()); + } else if (value instanceof BigDecimal) { + writableArray.put(((BigDecimal) value).doubleValue()); + } else if (value instanceof String) { + writableArray.put((String) value); + } else if (value instanceof JSObject || value instanceof JSONObject) { + writableArray.put(value instanceof JSObject ? (JSObject) value : (JSONObject) value); + } else if (value instanceof JSONArray) { + writableArray.put((JSONArray) value); + } else { + logger.log(SentryLevel.ERROR, "Could not convert object: " + value); + } + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error adding value to array: " + e.getMessage(), e); + } + } + + private static void addValueToWritableMap(JSObject writableMap, String key, Object value) { + try { + if (value == null) { + writableMap.put(key, JSONObject.NULL); + } else if (value instanceof Boolean) { + writableMap.put(key, (Boolean) value); + } else if (value instanceof Double) { + writableMap.put(key, (Double) value); + } else if (value instanceof Float) { + writableMap.put(key, ((Float) value).doubleValue()); + } else if (value instanceof Integer) { + writableMap.put(key, (Integer) value); + } else if (value instanceof Short) { + writableMap.put(key, ((Short) value).intValue()); + } else if (value instanceof Byte) { + writableMap.put(key, ((Byte) value).intValue()); + } else if (value instanceof Long) { + writableMap.put(key, ((Long) value).doubleValue()); + } else if (value instanceof BigInteger) { + writableMap.put(key, ((BigInteger) value).doubleValue()); + } else if (value instanceof BigDecimal) { + writableMap.put(key, ((BigDecimal) value).doubleValue()); + } else if (value instanceof String) { + writableMap.put(key, (String) value); + } else if (value instanceof JSONArray) { + writableMap.put(key, (JSONArray) value); + } else if (value instanceof JSObject || value instanceof JSONObject) { + writableMap.put(key, value instanceof JSObject ? (JSObject) value : (JSONObject) value); + } else { + logger.log(SentryLevel.ERROR, "Could not convert object: " + value); + } + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error adding value to map: " + e.getMessage(), e); + } + } +} diff --git a/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java b/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java index a74085db..ba517c65 100644 --- a/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java +++ b/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java @@ -4,7 +4,10 @@ import android.content.pm.PackageInfo; import io.sentry.ILogger; +import io.sentry.ScopesAdapter; +import io.sentry.SentryOptions; import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.SentryAndroidOptions; import io.sentry.vendor.Base64; import com.getcapacitor.JSObject; @@ -13,6 +16,8 @@ import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; +import org.json.JSONArray; + import io.sentry.Breadcrumb; import io.sentry.IScope; import io.sentry.Integration; @@ -29,9 +34,11 @@ import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; @CapacitorPlugin @@ -267,18 +274,26 @@ public void addBreadcrumb(final PluginCall breadcrumb) { Sentry.configureScope(scope -> { Breadcrumb breadcrumbInstance = new Breadcrumb(); - if (breadcrumb.getData().has("message")) { + JSObject breadcrumbData = breadcrumb.getData(); + + if (breadcrumbData.has("message")) { breadcrumbInstance.setMessage(breadcrumb.getString("message")); } - if (breadcrumb.getData().has("type")) { + if (breadcrumbData.has("type")) { breadcrumbInstance.setType(breadcrumb.getString("type")); } - if (breadcrumb.getData().has("category")) { + if (breadcrumbData.has("category")) { breadcrumbInstance.setCategory(breadcrumb.getString("category")); } + if (breadcrumbData.has("origin")) { + breadcrumbInstance.setOrigin(breadcrumbData.getString("origin")); + } else { + breadcrumbInstance.setOrigin("capacitor"); + } + if (breadcrumb.getData().has("level")) { switch (breadcrumb.getString("level")) { case "fatal": @@ -361,6 +376,52 @@ public void setTag(PluginCall call) { call.resolve(); } + @PluginMethod + public void fetchNativeDeviceContexts(PluginCall call) { + final SentryOptions options = ScopesAdapter.getInstance().getOptions(); + final IScope currentScope = InternalSentrySdk.getCurrentScope(); + + + JSObject callData = call.getData(); + if (options == null || currentScope == null) { + call.resolve(); + return; + } + final Map serialized = + InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); + + // Filter out breadcrumbs with origin "capacitor" from the serialized data before conversion + if (serialized.containsKey("breadcrumbs")) { + Object breadcrumbsObj = serialized.get("breadcrumbs"); + if (breadcrumbsObj instanceof List) { + List breadcrumbs = (List) breadcrumbsObj; + List filteredBreadcrumbs = new ArrayList<>(); + for (Object breadcrumbObj : breadcrumbs) { + if (breadcrumbObj instanceof Map) { + Map breadcrumb = (Map) breadcrumbObj; + Object origin = breadcrumb.get("origin"); + if (!"capacitor".equals(origin)) { + filteredBreadcrumbs.add(breadcrumb); + } + } else { + // If it's not a Map, keep it as-is + filteredBreadcrumbs.add(breadcrumbObj); + } + } + serialized.put("breadcrumbs", filteredBreadcrumbs); + } + } + + final Object deviceContext = CapSentryMapConverter.convertToWritable(serialized); + + if (deviceContext instanceof JSObject) { + call.resolve((JSObject) deviceContext); + } + else { + call.resolve(); + } + } + public void setEventOriginTag(SentryEvent event) { SdkVersion sdk = event.getSdk(); if (sdk != null) { @@ -400,3 +461,4 @@ public void addPackages(SentryEvent event, SdkVersion sdk) { } } } + diff --git a/ios/Sources/SentryCapacitorPlugin/SentryCapacitorPlugin.swift b/ios/Sources/SentryCapacitorPlugin/SentryCapacitorPlugin.swift index 0fb572de..29ea7acb 100644 --- a/ios/Sources/SentryCapacitorPlugin/SentryCapacitorPlugin.swift +++ b/ios/Sources/SentryCapacitorPlugin/SentryCapacitorPlugin.swift @@ -182,7 +182,7 @@ public class SentryCapacitorPlugin: CAPPlugin, CAPBridgedPlugin { } let extraContext = PrivateSentrySDKOnly.getExtraContext() - var context = contexts["context"] as? [String: Any] ?? [:] + var context = contexts["contexts"] as? [String: Any] ?? [:] if let deviceExtraContext = extraContext["device"] as? [String: Any] { var deviceContext = context["device"] as? [String: Any] ?? [:] @@ -200,7 +200,18 @@ public class SentryCapacitorPlugin: CAPPlugin, CAPBridgedPlugin { context["app"] = appContext } - contexts["context"] = context + // Remove capacitor breadcrumbs + if let breadcrumbs = contexts["breadcrumbs"] as? [[String: Any]] { + let filteredBreadcrumbs = breadcrumbs.filter { breadcrumb in + guard let origin = breadcrumb["origin"] as? String else { + return true + } + return origin != "capacitor" + } + contexts["breadcrumbs"] = filteredBreadcrumbs + } + + contexts["contexts"] = context call.resolve(contexts as PluginCallResultData) } @@ -293,6 +304,12 @@ public class SentryCapacitorPlugin: CAPPlugin, CAPBridgedPlugin { breadcrumb.category = category } + if let origin = call.getString("origin") { + breadcrumb.origin = origin + } else { + breadcrumb.origin = "capacitor" + } + breadcrumb.type = call.getString("type") breadcrumb.message = call.getString("message") breadcrumb.data = call.getObject("data") diff --git a/src/breadcrumb.ts b/src/breadcrumb.ts index 51b49951..e7727d6c 100644 --- a/src/breadcrumb.ts +++ b/src/breadcrumb.ts @@ -1,3 +1,42 @@ -import type { SeverityLevel } from '@sentry/core'; +import type { Breadcrumb, SeverityLevel } from '@sentry/core'; +import { severityLevelFromString } from '@sentry/core'; export const DEFAULT_BREADCRUMB_LEVEL: SeverityLevel = 'info'; + +type BreadcrumbCandidate = { + [K in keyof Partial]: unknown; +}; + +/** + * Convert plain object to a valid Breadcrumb + */ +export function breadcrumbFromObject(candidate: BreadcrumbCandidate): Breadcrumb { + const breadcrumb: Breadcrumb = {}; + + if (typeof candidate.type === 'string') { + breadcrumb.type = candidate.type; + } + if (typeof candidate.level === 'string') { + breadcrumb.level = severityLevelFromString(candidate.level); + } + if (typeof candidate.event_id === 'string') { + breadcrumb.event_id = candidate.event_id; + } + if (typeof candidate.category === 'string') { + breadcrumb.category = candidate.category; + } + if (typeof candidate.message === 'string') { + breadcrumb.message = candidate.message; + } + if (typeof candidate.data === 'object' && candidate.data !== null) { + breadcrumb.data = candidate.data; + } + if (typeof candidate.timestamp === 'string') { + const timestampSeconds = Date.parse(candidate.timestamp) / 1000; // breadcrumb timestamp is in seconds + if (!isNaN(timestampSeconds)) { + breadcrumb.timestamp = timestampSeconds; + } + } + + return breadcrumb; +} diff --git a/src/integrations/default.ts b/src/integrations/default.ts index af7602c0..dcf7d9fa 100644 --- a/src/integrations/default.ts +++ b/src/integrations/default.ts @@ -1,4 +1,5 @@ -import { type Integration,consoleLoggingIntegration } from '@sentry/core'; +import { breadcrumbsIntegration, browserApiErrorsIntegration, browserSessionIntegration, globalHandlersIntegration } from '@sentry/browser'; +import { type Integration,dedupeIntegration, functionToStringIntegration, inboundFiltersIntegration, linkedErrorsIntegration } from '@sentry/core'; import type { CapacitorOptions } from '../options'; import { deviceContextIntegration } from './devicecontext'; import { eventOriginIntegration } from './eventorigin'; @@ -18,11 +19,30 @@ export function getDefaultIntegrations( integrations.push(nativeReleaseIntegration()); integrations.push(eventOriginIntegration()); integrations.push(sdkInfoIntegration()); - integrations.push(consoleLoggingIntegration()); if (options.enableNative) { integrations.push(deviceContextIntegration()); } + // @sentry/browser integrations + integrations.push( + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration(), + functionToStringIntegration(), + browserApiErrorsIntegration(), + breadcrumbsIntegration(), + globalHandlersIntegration(), + linkedErrorsIntegration(), + dedupeIntegration(), + ); + + if (options.enableAutoSessionTracking && !options.enableNative) { + integrations.push(browserSessionIntegration()); + } + // end @sentry/browser integrations + + // @sentry/vue integrations must be added manually. + // @sentry/react, @sentry/angular @sentry/nuxt dont require any integrations. + return integrations; } diff --git a/src/integrations/devicecontext.ts b/src/integrations/devicecontext.ts index 20c17b45..3a369b18 100644 --- a/src/integrations/devicecontext.ts +++ b/src/integrations/devicecontext.ts @@ -1,5 +1,6 @@ -import type { Contexts, Event, Integration } from '@sentry/core'; +import type { Client, Contexts, Event, EventHint, Integration } from '@sentry/core'; import { debug } from '@sentry/core'; +import { breadcrumbFromObject } from '../breadcrumb'; import { NATIVE } from '../wrapper'; const INTEGRATION_NAME = 'DeviceContext'; @@ -11,18 +12,30 @@ export const deviceContextIntegration = (): Integration => { }; }; -async function processEvent(event: Event): Promise { +async function processEvent(event: Event, _hint: EventHint, client: Client): Promise { try { - const contexts = await NATIVE.fetchNativeDeviceContexts(); - const context = contexts['context'] as Contexts; + const nativeContexts = await NATIVE.fetchNativeDeviceContexts(); + const context = nativeContexts['contexts'] as Contexts; event.contexts = { ...context, ...event.contexts }; - if ('user' in contexts) { - const user = contexts['user']; + if ('user' in nativeContexts) { + const user = nativeContexts['user']; if (!event.user) { event.user = { ...user }; } } + + const nativeBreadcrumbs = Array.isArray(nativeContexts['breadcrumbs']) + ? nativeContexts['breadcrumbs'].map(breadcrumbFromObject) + : undefined; + if (nativeBreadcrumbs) { + const maxBreadcrumbs = client?.getOptions().maxBreadcrumbs ?? 100; // Default is 100. + event.breadcrumbs = nativeBreadcrumbs + .concat(event.breadcrumbs || []) // concatenate the native and js breadcrumbs + .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)) // sort by timestamp + .slice(-maxBreadcrumbs); // keep the last maxBreadcrumbs + } + } catch (e) { debug.log(`Failed to get device context from native: ${e}`); } diff --git a/src/sdk.ts b/src/sdk.ts index 5dff1660..03774c69 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -42,7 +42,7 @@ export function init( // makeMain(capacitorHub); const defaultIntegrations: false | Integration[] = passedOptions.defaultIntegrations === undefined - ? getDefaultIntegrations(passedOptions) + ? getDefaultIntegrations(finalOptions) : passedOptions.defaultIntegrations; finalOptions.integrations = getIntegrationsToSetup({ diff --git a/src/wrapper.ts b/src/wrapper.ts index 04fe99f3..18580729 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -128,11 +128,6 @@ export const NATIVE = { throw this._NativeClientError; } - if (this.platform !== 'ios') { - // Only ios uses deviceContexts, return an empty object. - return {}; - } - return SentryCapacitor.fetchNativeDeviceContexts(); }, @@ -376,19 +371,6 @@ export const NATIVE = { // @ts-ignore Android still uses the old message object, without this the serialization of events will break. event.message = { message: event.message }; } - /* - We do this to avoid duplicate breadcrumbs on Android as sentry-android applies the breadcrumbs - from the native scope onto every envelope sent through it. This scope will contain the breadcrumbs - sent through the scope sync feature. This causes duplicate breadcrumbs. - We then remove the breadcrumbs in all cases but if it is handled == false, - this is a signal that the app would crash and android would lose the breadcrumbs by the time the app is restarted to read - the envelope. - Since unhandled errors from Javascript are not going to crash the App, we can't rely on the - handled flag for filtering breadcrumbs. - */ - if (event.breadcrumbs) { - event.breadcrumbs = []; - } return [itemHeader, event]; } } diff --git a/test/breadcrumb.test.ts b/test/breadcrumb.test.ts new file mode 100644 index 00000000..0bb10f42 --- /dev/null +++ b/test/breadcrumb.test.ts @@ -0,0 +1,51 @@ +import type { Breadcrumb } from '@sentry/core'; +import { breadcrumbFromObject } from '../src/breadcrumb'; + +describe('Breadcrumb', () => { + describe('breadcrumbFromObject', () => { + it('convert a plain object to a valid Breadcrumb', () => { + const candidate = { + type: 'test', + level: 'info', + event_id: '1234', + category: 'test', + message: 'test', + data: { + test: 'test', + }, + timestamp: '2020-01-01T00:00:00.000Z', + }; + const breadcrumb = breadcrumbFromObject(candidate); + expect(breadcrumb).toEqual({ + type: 'test', + level: 'info', + event_id: '1234', + category: 'test', + message: 'test', + data: { + test: 'test', + }, + timestamp: 1577836800, + }); + }); + + it('convert plain object with invalid timestamp to a valid Breadcrumb', () => { + const candidate = { + type: 'test', + level: 'info', + timestamp: 'invalid', + }; + const breadcrumb = breadcrumbFromObject(candidate); + expect(breadcrumb).toEqual({ + type: 'test', + level: 'info', + }); + }); + + it('convert empty object to a valid Breadcrumb', () => { + const candidate = {}; + const breadcrumb = breadcrumbFromObject(candidate); + expect(breadcrumb).toEqual({}); + }); + }); +}); diff --git a/test/integrations/devicecontext.test.ts b/test/integrations/devicecontext.test.ts new file mode 100644 index 00000000..8e1cb7ec --- /dev/null +++ b/test/integrations/devicecontext.test.ts @@ -0,0 +1,558 @@ +import type { Client, Event, EventHint } from '@sentry/core'; +import { deviceContextIntegration } from '../../src/integrations/devicecontext'; +import { NATIVE } from '../mockWrapper'; + +jest.mock('../../src/wrapper', () => ({ + NATIVE: require('../mockWrapper').NATIVE, +})); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + debug: { + log: jest.fn(), + }, + }; +}); + +describe('DeviceContext Integration', () => { + let mockClient: Client; + let mockGetOptions: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetOptions = jest.fn(() => ({ maxBreadcrumbs: 100 })); + mockClient = { + getOptions: mockGetOptions, + } as unknown as Client; + NATIVE.fetchNativeDeviceContexts.mockResolvedValue({}); + }); + + describe('integration setup', () => { + it('should return integration with correct name', () => { + const integration = deviceContextIntegration(); + expect(integration.name).toBe('DeviceContext'); + expect(integration.processEvent).toBeDefined(); + }); + }); + + describe('context merging', () => { + it('should merge native contexts into event', async () => { + const nativeContexts = { + contexts: { + device: { + name: 'iPhone', + model: 'iPhone 13', + }, + os: { + name: 'iOS', + version: '15.0', + }, + }, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = {}; + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.contexts).toEqual({ + device: { + name: 'iPhone', + model: 'iPhone 13', + }, + os: { + name: 'iOS', + version: '15.0', + }, + }); + }); + + it('should merge event contexts with native contexts, event taking precedence', async () => { + const nativeContexts = { + contexts: { + device: { + name: 'iPhone', + model: 'iPhone 13', + }, + }, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + contexts: { + device: { + name: 'Android Device', + model: 'Pixel 5', + }, + app: { + name: 'MyApp', + }, + }, + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.contexts).toEqual({ + device: { + name: 'Android Device', + model: 'Pixel 5', + }, + app: { + name: 'MyApp', + }, + }); + }); + + it('should handle empty native contexts', async () => { + NATIVE.fetchNativeDeviceContexts.mockResolvedValue({ contexts: {} }); + + const event: Event = { + contexts: { + app: { + name: 'MyApp', + }, + }, + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.contexts).toEqual({ + app: { + name: 'MyApp', + }, + }); + }); + }); + + describe('user merging', () => { + it('should merge native user into event when event has no user', async () => { + const nativeContexts = { + contexts: {}, + user: { + id: 'native-user-id', + email: 'native@example.com', + username: 'native-user', + }, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = {}; + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.user).toEqual({ + id: 'native-user-id', + email: 'native@example.com', + username: 'native-user', + }); + }); + + it('should not override event user when event already has user', async () => { + const nativeContexts = { + contexts: {}, + user: { + id: 'native-user-id', + email: 'native@example.com', + }, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + user: { + id: 'event-user-id', + email: 'event@example.com', + }, + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.user).toEqual({ + id: 'event-user-id', + email: 'event@example.com', + }); + }); + + it('should handle user in contexts but not merge if not in contexts object', async () => { + const nativeContexts = { + contexts: { + device: { + name: 'iPhone', + }, + }, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = {}; + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.user).toBeUndefined(); + }); + }); + + describe('breadcrumb processing', () => { + it('should process and merge native breadcrumbs with event breadcrumbs', async () => { + const nativeBreadcrumbs = [ + { + type: 'navigation', + level: 'info', + message: 'Native breadcrumb 1', + timestamp: '2023-01-01T00:00:00.000Z', + }, + { + type: 'user', + level: 'info', + message: 'Native breadcrumb 2', + timestamp: '2023-01-01T00:00:01.000Z', + }, + ]; + + const nativeContexts = { + contexts: {}, + breadcrumbs: nativeBreadcrumbs, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + breadcrumbs: [ + { + type: 'log', + level: 'info', + message: 'JS breadcrumb', + timestamp: 1672531201.5, // 2023-01-01T00:00:01.500Z + }, + ], + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(3); + expect(result?.breadcrumbs?.[0]?.message).toBe('Native breadcrumb 1'); + expect(result?.breadcrumbs?.[1]?.message).toBe('Native breadcrumb 2'); + expect(result?.breadcrumbs?.[2]?.message).toBe('JS breadcrumb'); + }); + + it('should sort breadcrumbs by timestamp', async () => { + const nativeBreadcrumbs = [ + { + type: 'navigation', + level: 'info', + message: 'Native breadcrumb later', + timestamp: '2023-01-01T00:00:02.000Z', + }, + ]; + + const nativeContexts = { + contexts: {}, + breadcrumbs: nativeBreadcrumbs, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + breadcrumbs: [ + { + type: 'log', + level: 'info', + message: 'JS breadcrumb earlier', + timestamp: 1672531200.5, // 2023-01-01T00:00:00.500Z + }, + ], + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(2); + expect(result?.breadcrumbs?.[0]?.message).toBe('JS breadcrumb earlier'); + expect(result?.breadcrumbs?.[1]?.message).toBe('Native breadcrumb later'); + }); + + it('should limit breadcrumbs to maxBreadcrumbs from client options', async () => { + mockGetOptions.mockReturnValue({ maxBreadcrumbs: 3 }); + + const nativeBreadcrumbs = Array.from({ length: 5 }, (_, i) => ({ + type: 'navigation', + level: 'info', + message: `Native breadcrumb ${i}`, + timestamp: `2023-01-01T00:00:0${i}.000Z`, + })); + + const nativeContexts = { + contexts: {}, + breadcrumbs: nativeBreadcrumbs, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + breadcrumbs: [ + { + type: 'log', + level: 'info', + message: 'JS breadcrumb', + timestamp: 1672531200.0, + }, + ], + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(3); + // Should keep the last 3 breadcrumbs after sorting + expect(result?.breadcrumbs?.[result?.breadcrumbs?.length - 1]?.message).toBe('Native breadcrumb 4'); + }); + + it('should use default maxBreadcrumbs of 100 when not specified', async () => { + mockGetOptions.mockReturnValue({}); + + const nativeBreadcrumbs = Array.from({ length: 50 }, (_, i) => ({ + type: 'navigation', + level: 'info', + message: `Native breadcrumb ${i}`, + timestamp: `2023-01-01T00:00:${String(i).padStart(2, '0')}.000Z`, + })); + + const nativeContexts = { + contexts: {}, + breadcrumbs: nativeBreadcrumbs, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = {}; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(50); + }); + + it('should handle breadcrumbs when event has no breadcrumbs', async () => { + const nativeBreadcrumbs = [ + { + type: 'navigation', + level: 'info', + message: 'Native breadcrumb', + timestamp: '2023-01-01T00:00:00.000Z', + }, + ]; + + const nativeContexts = { + contexts: {}, + breadcrumbs: nativeBreadcrumbs, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = {}; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(1); + expect(result?.breadcrumbs?.[0]?.message).toBe('Native breadcrumb'); + }); + + it('should handle when native breadcrumbs is not an array', async () => { + const nativeContexts = { + contexts: {}, + breadcrumbs: 'not an array', + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + breadcrumbs: [ + { + type: 'log', + level: 'info', + message: 'JS breadcrumb', + timestamp: 1672531200.0, + }, + ], + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(1); + expect(result?.breadcrumbs?.[0]?.message).toBe('JS breadcrumb'); + }); + + it('should handle when native breadcrumbs is undefined', async () => { + const nativeContexts = { + contexts: {}, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + breadcrumbs: [ + { + type: 'log', + level: 'info', + message: 'JS breadcrumb', + timestamp: 1672531200.0, + }, + ], + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(1); + expect(result?.breadcrumbs?.[0]?.message).toBe('JS breadcrumb'); + }); + + it('should handle breadcrumbs without timestamps', async () => { + const nativeBreadcrumbs = [ + { + type: 'navigation', + level: 'info', + message: 'Native breadcrumb no timestamp', + }, + { + type: 'user', + level: 'info', + message: 'Native breadcrumb with timestamp', + timestamp: '2023-01-01T00:00:01.000Z', + }, + ]; + + const nativeContexts = { + contexts: {}, + breadcrumbs: nativeBreadcrumbs, + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = {}; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.breadcrumbs).toHaveLength(2); + // Breadcrumb without timestamp should be treated as 0 + expect(result?.breadcrumbs?.[0]?.message).toBe('Native breadcrumb no timestamp'); + }); + }); + + describe('error handling', () => { + it('should handle errors from fetchNativeDeviceContexts gracefully', async () => { + const error = new Error('Native fetch failed'); + NATIVE.fetchNativeDeviceContexts.mockRejectedValue(error); + + const event: Event = { + contexts: { + app: { + name: 'MyApp', + }, + }, + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + // Event should be returned unchanged + expect(result?.contexts).toEqual({ + app: { + name: 'MyApp', + }, + }); + }); + + it('should return event even when native fetch throws', async () => { + NATIVE.fetchNativeDeviceContexts.mockRejectedValue(new Error('Network error')); + + const event: Event = { + message: 'Test event', + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.message).toBe('Test event'); + }); + }); + + describe('combined scenarios', () => { + it('should handle contexts, user, and breadcrumbs together', async () => { + const nativeContexts = { + contexts: { + device: { + name: 'iPhone', + }, + }, + user: { + id: 'user-123', + email: 'user@example.com', + }, + breadcrumbs: [ + { + type: 'navigation', + level: 'info', + message: 'Native breadcrumb', + timestamp: '2023-01-01T00:00:00.000Z', + }, + ], + }; + + NATIVE.fetchNativeDeviceContexts.mockResolvedValue(nativeContexts); + + const event: Event = { + contexts: { + app: { + name: 'MyApp', + }, + }, + breadcrumbs: [ + { + type: 'log', + level: 'info', + message: 'JS breadcrumb', + timestamp: 1672531201.0, + }, + ], + }; + + const integration = deviceContextIntegration(); + const result = await integration.processEvent!(event, {} as EventHint, mockClient); + + expect(result?.contexts).toEqual({ + device: { + name: 'iPhone', + }, + app: { + name: 'MyApp', + }, + }); + + if (result === null) { + throw new Error('Result is undefined'); + } + + expect(result.user).toEqual({ + id: 'user-123', + email: 'user@example.com', + }); + expect(result.breadcrumbs).toHaveLength(2); + expect(result.breadcrumbs![0]?.message).toBe('Native breadcrumb'); + expect(result.breadcrumbs![1]?.message).toBe('JS breadcrumb'); + }); + }); +}); + diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts index a8d7f49c..2f7a1acd 100644 --- a/test/wrapper.test.ts +++ b/test/wrapper.test.ts @@ -55,7 +55,6 @@ import { Capacitor } from '@capacitor/core'; import { SentryCapacitor } from '../src/plugin'; import { base64StringFromByteArray } from '../src/vendor/fromByteArray'; import { NATIVE } from '../src/wrapper'; -import { Base64StringToString } from './vendor/base64Converter'; beforeEach(() => { getStringBytesLengthValue = 1; @@ -84,7 +83,7 @@ describe('Tests Native Wrapper', () => { // @ts-ignore ignore app and vue since they are part of Sentry/Vue and not Capacitor. await NATIVE.initNativeSdk({ dsn: 'test', enableNative: true, app: 'test', vue: 'test' }); - const nativeOption = initNativeSdk.mock.calls[0][0].options; + const nativeOption = initNativeSdk.mock.calls[0]?.[0]?.options; expect(SentryCapacitor.initNativeSdk).toHaveBeenCalledTimes(1); // @ts-ignore Not part of Capacitor Options but it is extended by Vue Options. expect(nativeOption.app).toBeUndefined(); @@ -110,16 +109,16 @@ describe('Tests Native Wrapper', () => { tracesSampler: jest.fn(), }); - const nativeOption = initNativeSdk.mock.calls[0][0].options; + const nativeOption = initNativeSdk.mock.calls[0]?.[0]?.options; expect(SentryCapacitor.initNativeSdk).toHaveBeenCalledTimes(1); // @ts-ignore Not part of Capacitor Options but it is extended by Vue Options. - expect(nativeOption.integrations).toBeUndefined(); - expect(nativeOption.defaultIntegrations).toBeUndefined(); - expect(nativeOption.beforeSend).toBeUndefined(); - expect(nativeOption.beforeSendTransaction).toBeUndefined(); - expect(nativeOption.beforeBreadcrumb).toBeUndefined(); - expect(nativeOption.transport).toBeUndefined(); - expect(nativeOption.tracesSampler).toBeUndefined(); + expect(nativeOption?.integrations).toBeUndefined(); + expect(nativeOption?.defaultIntegrations).toBeUndefined(); + expect(nativeOption?.beforeSend).toBeUndefined(); + expect(nativeOption?.beforeSendTransaction).toBeUndefined(); + expect(nativeOption?.beforeBreadcrumb).toBeUndefined(); + expect(nativeOption?.transport).toBeUndefined(); + expect(nativeOption?.tracesSampler).toBeUndefined(); expect(initNativeSdk).toHaveBeenCalled(); }); @@ -217,53 +216,6 @@ describe('Tests Native Wrapper', () => { expect(SentryCapacitor.captureEnvelope).not.toHaveBeenCalled(); }); - test('Clears breadcrumbs on Android if there is no exception', async () => { - NATIVE.platform = 'android'; - - const event = { - event_id: 'event0', - message: 'test', - breadcrumbs: [ - { - message: 'crumb!', - }, - ], - sdk: { - name: 'test-sdk-name', - version: '1.2.3', - }, - }; - - const expectedHeader = JSON.stringify({ - event_id: event.event_id, - sent_at: '123' - }); - const expectedItem = JSON.stringify({ - type: 'event', - content_type: 'application/vnd.sentry.items.log+json', - length: 116, - }); - const expectedPayload = JSON.stringify({ - ...event, - breadcrumbs: [], - message: { - message: event.message, - }, - }); - - const env = createEnvelope({ event_id: event.event_id, sent_at: '123' }, [ - [{ type: 'event' }, event] as EventItem, - ]); - - const captureEnvelopeSpy = jest.spyOn(SentryCapacitor, 'captureEnvelope'); - - await NATIVE.sendEnvelope(env); - - expect(SentryCapacitor.captureEnvelope).toHaveBeenCalledTimes(1); - expect(Base64StringToString(captureEnvelopeSpy.mock.calls[0][0].envelope)).toMatch( - `${expectedHeader}\n${expectedItem}\n${expectedPayload}`); - }); - test('Has a valid EOF string', async () => { const [expectedEOF = -1/* Placeholder */] = utf8ToBytes('\n'); const expectedEnvelopeBytes = utf8ToBytes(JSON.stringify({ foo: 'bar' })); @@ -278,130 +230,9 @@ describe('Tests Native Wrapper', () => { expect(expectedEOF).not.toBe(-1); expect(SentryCapacitor.captureEnvelope).toHaveBeenCalledTimes(1); - expect(captureEnvelopeSpy.mock.calls[0][0].envelope).toEqual(expectedEnvelopeBase64); - }); - - test('Clears breadcrumbs on Android if there is a handled exception', async () => { - NATIVE.platform = 'android'; - - const event = { - event_id: 'event0', - message: 'test', - breadcrumbs: [ - { - message: 'crumb!', - }, - ], - exception: { - values: [{ - mechanism: { - handled: true - } - }] - }, - sdk: { - name: 'test-sdk-name', - version: '1.2.3', - }, - }; - - const env = createEnvelope({ event_id: event.event_id, sent_at: '123' }, [ - [{ type: 'event' }, event] as EventItem, - ]); - - const expectedHeader = JSON.stringify({ - event_id: event.event_id, - sent_at: '123' - }); - const expectedItem = JSON.stringify({ - type: 'event', - content_type: 'application/vnd.sentry.items.log+json', - length: 172, - }); - const expectedPayload = JSON.stringify({ - ...event, - breadcrumbs: [], - message: { - message: event.message, - }, - exception: { - values: [{ - mechanism: { - handled: true - } - }] - } - }); - - const captureEnvelopeSpy = jest.spyOn(SentryCapacitor, 'captureEnvelope'); - - await NATIVE.sendEnvelope(env); - - expect(SentryCapacitor.captureEnvelope).toHaveBeenCalledTimes(1); - expect(Base64StringToString(captureEnvelopeSpy.mock.calls[0][0].envelope)).toMatch( - `${expectedHeader}\n${expectedItem}\n${expectedPayload}\n`); + expect(captureEnvelopeSpy.mock.calls[0]?.[0]?.envelope).toEqual(expectedEnvelopeBase64); }); - test('Clears breadcrumbs on Android if there is a handled exception', async () => { - NATIVE.platform = 'android'; - - const event = { - event_id: 'event0', - message: 'test', - breadcrumbs: [ - { - message: 'crumb!', - }, - ], - exception: { - values: [{ - mechanism: { - handled: true - } - }] - }, - sdk: { - name: 'test-sdk-name', - version: '1.2.3', - }, - }; - - const expectedHeader = JSON.stringify({ - event_id: event.event_id, - sent_at: '123' - }); - const expectedItem = JSON.stringify({ - type: 'event', - content_type: 'application/vnd.sentry.items.log+json', - length: 172, - }); - const expectedPayload = JSON.stringify({ - ...event, - breadcrumbs: [], - message: { - message: event.message, - }, - exception: { - values: [{ - mechanism: { - handled: true - } - }] - } - }); - - const env = createEnvelope({ event_id: event.event_id, sent_at: '123' }, [ - [{ type: 'event' }, event] as EventItem, - ]); - - const captureEnvelopeSpy = jest.spyOn(SentryCapacitor, 'captureEnvelope'); - - await NATIVE.sendEnvelope(env); - - expect(SentryCapacitor.captureEnvelope).toHaveBeenCalledTimes(1); - expect(Base64StringToString(captureEnvelopeSpy.mock.calls[0][0].envelope)).toMatch( - `${expectedHeader}\n${expectedItem}\n${expectedPayload}\n`); - }); test('has statusCode 200 on success', async () => { const captureEnvelopeSpy = jest.spyOn(SentryCapacitor, 'captureEnvelope'); @@ -622,15 +453,6 @@ describe('Tests Native Wrapper', () => { expect(SentryCapacitor.fetchNativeDeviceContexts).toHaveBeenCalled(); }); - test('returns empty object on android', async () => { - NATIVE.platform = 'android'; - - await expect(NATIVE.fetchNativeDeviceContexts()).resolves.toMatchObject( - {} - ); - - expect(SentryCapacitor.fetchNativeDeviceContexts).not.toHaveBeenCalled(); - }); }); describe('closeNativeSdk', () => {