Repository: getsentry/sentry-capacitor
Pull request #61 (2021-07-05 10:30:47)

feat: Finish scope sync for both iOS and Android
Add scope sync methods for `setTag`, `setExtra`, `setUser`, `addBreadcrumb`, `clearBreadcrumb` for iOS. 

Fixes `setUser` not taking non-default data and `clearBreadcrumbs` on Android.

Adds test for the JS side of the bridge, and methods to test scope sync on the sample apps.

Tested on the v3 sample app.

jennmueng: @philipphofmann Do you mind having a quick run through of this as it contains a lot of swift? (and the fact that I haven't really done much swift before) Thanks!

Patch:
diff --git a/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java b/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java
index fbad3f4..d433a47 100644
--- a/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java
+++ b/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java
@@ -65,7 +65,7 @@ public class SentryCapacitor extends Plugin {
         SentryAndroid.init(
             this.getContext(),
             options -> {
-                if (capOptions.getData().has("debug") && capOptions.getBoolean("debug")) {
+                if (capOptions.has("debug") && capOptions.getBool("debug")) {
                     options.setDebug(true);
                     logger.setLevel(Level.INFO);
                 }
@@ -74,30 +74,30 @@ public class SentryCapacitor extends Plugin {
                 logger.info(String.format("Starting with DSN: '%s'", dsn));
                 options.setDsn(dsn);
 
-                if (capOptions.getData().has("environment") && capOptions.getString("environment") != null) {
+                if (capOptions.has("environment") && capOptions.getString("environment") != null) {
                     options.setEnvironment(capOptions.getString("environment"));
                 }
 
-                if (capOptions.getData().has("enableAutoSessionTracking")) {
-                    options.setEnableSessionTracking(capOptions.getBoolean("enableAutoSessionTracking"));
+                if (capOptions.has("enableAutoSessionTracking")) {
+                    options.setEnableAutoSessionTracking(capOptions.getBool("enableAutoSessionTracking"));
                 }
 
-                if (capOptions.getData().has("sessionTrackingIntervalMillis")) {
-                    options.setSessionTrackingIntervalMillis(capOptions.getInt("sessionTrackingIntervalMillis"));
+                if (capOptions.has("sessionTrackingIntervalMillis")) {
+                    options.setSessionTrackingIntervalMillis(capOptions.getInteger("sessionTrackingIntervalMillis"));
                 }
 
-                if (capOptions.getData().has("enableNdkScopeSync")) {
-                    options.setEnableScopeSync(capOptions.getBoolean("enableNdkScopeSync"));
+                if (capOptions.has("enableNdkScopeSync")) {
+                    options.setEnableScopeSync(capOptions.getBool("enableNdkScopeSync"));
                 }
 
-                if (capOptions.getData().has("attachStacktrace")) {
-                    options.setAttachStacktrace(capOptions.getBoolean("attachStacktrace"));
+                if (capOptions.has("attachStacktrace")) {
+                    options.setAttachStacktrace(capOptions.getBool("attachStacktrace"));
                 }
 
-                if (capOptions.getData().has("attachThreads")) {
+                if (capOptions.has("attachThreads")) {
                     // JS use top level stacktraces and android attaches Threads which hides them so
                     // by default we hide.
-                    options.setAttachThreads(capOptions.getBoolean("attachThreads"));
+                    options.setAttachThreads(capOptions.getBool("attachThreads"));
                 }
 
                 options.setBeforeSend(
@@ -109,7 +109,7 @@ public class SentryCapacitor extends Plugin {
                     }
                 );
 
-                if (capOptions.getData().has("enableNativeCrashHandling") && !capOptions.getBoolean("enableNativeCrashHandling")) {
+                if (capOptions.has("enableNativeCrashHandling") && !capOptions.getBool("enableNativeCrashHandling")) {
                     final List<Integration> integrations = options.getIntegrations();
                     for (final Integration integration : integrations) {
                         if (
@@ -128,43 +128,47 @@ public class SentryCapacitor extends Plugin {
 
         JSObject resp = new JSObject();
         resp.put("value", true);
-        capOptions.resolve(resp);
+        call.resolve(resp);
     }
 
     @PluginMethod
     public void setUser(PluginCall call) {
         Sentry.configureScope(scope -> {
-            if (!call.getData().has("user") && !call.getData().has("otherUserKeys")) {
+            JSObject defaultUserKeys = call.getObject("defaultUserKeys");
+            JSObject otherUserKeys = call.getObject("otherUserKeys");
+
+            if (defaultUserKeys == null && otherUserKeys == null) {
                 scope.setUser(null);
             } else {
                 User userInstance = new User();
 
-                if (call.getData().has("user")) {
-                    JSObject user = call.getObject("user");
-                    if (user.has("email")) {
-                        userInstance.setEmail(user.getString("email"));
+                if (defaultUserKeys != null) {
+                    if (defaultUserKeys.has("email")) {
+                        userInstance.setEmail(defaultUserKeys.getString("email"));
                     }
 
-                    if (user.has("id")) {
-                        userInstance.setId(user.getString("id"));
+                    if (defaultUserKeys.has("id")) {
+                        userInstance.setId(defaultUserKeys.getString("id"));
                     }
 
-                    if (user.has("username")) {
-                        userInstance.setUsername(user.getString("username"));
+                    if (defaultUserKeys.has("username")) {
+                        userInstance.setUsername(defaultUserKeys.getString("username"));
                     }
 
-                    if (user.has("ip_address")) {
-                        userInstance.setIpAddress(user.getString("ip_address"));
+                    if (defaultUserKeys.has("ip_address")) {
+                        userInstance.setIpAddress(defaultUserKeys.getString("ip_address"));
                     }
                 }
 
-                if (call.getData().has("otherUserKeys")) {
-                    Map<String, String> otherUserKeysMap = (Map<String, String>) call.getObject("otherUserKeys");
+                if (otherUserKeys != null) {
+                    Map<String, String> otherUserKeysMap = new HashMap<>();
+                    Iterator<String> it = otherUserKeys.keys();
+
+                    while (it.hasNext()) {
+                      String key = it.next();
+                      String value = otherUserKeys.getString(key);
 
-                    for (Map.Entry<String, String> entry: otherUserKeysMap.entrySet()) {
-                        String key = entry.getKey();
-                        String value = entry.getValue();
-                        otherUserKeysMap.put(key, value);
+                      otherUserKeysMap.put(key, value);
                     }
 
                     userInstance.setOthers(otherUserKeysMap);
@@ -297,7 +301,7 @@ public class SentryCapacitor extends Plugin {
     }
 
     @PluginMethod
-    public void clearBreadcrumbs() {
+    public void clearBreadcrumbs(PluginCall call) {
         Sentry.configureScope(scope -> {
             scope.clearBreadcrumbs();
         });
@@ -305,11 +309,11 @@ public class SentryCapacitor extends Plugin {
 
     @PluginMethod
     public void setExtra(PluginCall call) {
-        if (call.getData().has("key") && call.getData().has("extra")) {
+        if (call.getData().has("key") && call.getData().has("value")) {
             Sentry.configureScope(scope -> {
                 String key = call.getString("key");
-                String extra = call.getString("extra");
-                scope.setExtra(key, extra);
+                String value = call.getString("value");
+                scope.setExtra(key, value);
             });
         }
         call.resolve();
diff --git a/example/ionic-angular-v2/src/app/tab1/tab1.page.html b/example/ionic-angular-v2/src/app/tab1/tab1.page.html
index c569fc4..ccefe2b 100644
--- a/example/ionic-angular-v2/src/app/tab1/tab1.page.html
+++ b/example/ionic-angular-v2/src/app/tab1/tab1.page.html
@@ -49,5 +49,22 @@
       Ignore logging an error that uses excluded type
       <ion-button (click)="ignoredTypeError()">Ignored Type Error</ion-button>
     </label>
+
+    <label>
+      Set Scope Properties
+      <ion-button (click)="setScope()">Set Scope</ion-button>
+    </label>
+    <label>
+      Clear User
+      <ion-button (click)="clearUser()">Clear User</ion-button>
+    </label>
+    <label>
+      Clear Breadcrumbs
+      <ion-button (click)="clearBreadcrumbs()">Clear Breadcrumbs</ion-button>
+    </label>
+    <label>
+      Clear Test Context
+      <ion-button (click)="clearTestContext()">Clear Test Context</ion-button>
+    </label>
   </section>
 </ion-content>
diff --git a/example/ionic-angular-v2/src/app/tab1/tab1.page.ts b/example/ionic-angular-v2/src/app/tab1/tab1.page.ts
index aae7f6c..4035266 100644
--- a/example/ionic-angular-v2/src/app/tab1/tab1.page.ts
+++ b/example/ionic-angular-v2/src/app/tab1/tab1.page.ts
@@ -44,4 +44,101 @@ export class Tab1Page {
       new RangeError('Exception that will be ignored because of its type'),
     );
   }
+
+  public setScope(): void {
+    const dateString = new Date().toString();
+
+    Sentry.setUser({
+      id: 'test-id-0',
+      email: 'testing@testing.test',
+      username: 'USER-TEST',
+      specialField: 'special user field',
+      specialFieldNumber: 418,
+    });
+
+    Sentry.setTag('SINGLE-TAG', dateString);
+    // @ts-ignore
+    Sentry.setTag('SINGLE-TAG-NUMBER', 100);
+    Sentry.setTags({
+      'MULTI-TAG-0': dateString,
+      'MULTI-TAG-1': dateString,
+      'MULTI-TAG-2': dateString,
+    });
+
+    Sentry.setExtra('SINGLE-EXTRA', dateString);
+    Sentry.setExtra('SINGLE-EXTRA-NUMBER', 100);
+    Sentry.setExtra('SINGLE-EXTRA-OBJECT', {
+      message: 'I am a teapot',
+      status: 418,
+      array: ['boo', 100, 400, { objectInsideArray: 'foobar' }],
+    });
+    Sentry.setExtras({
+      'MULTI-EXTRA-0': dateString,
+      'MULTI-EXTRA-1': dateString,
+      'MULTI-EXTRA-2': dateString,
+    });
+
+    Sentry.setContext('TEST-CONTEXT', {
+      stringTest: 'Hello',
+      numberTest: 404,
+      objectTest: {
+        foo: 'bar',
+      },
+      arrayTest: ['foo', 'bar', 400],
+      nullTest: null,
+      undefinedTest: undefined,
+    });
+
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Info,
+      message: `TEST-BREADCRUMB-INFO: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Debug,
+      message: `TEST-BREADCRUMB-DEBUG: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Error,
+      message: `TEST-BREADCRUMB-ERROR: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Fatal,
+      message: `TEST-BREADCRUMB-FATAL: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Info,
+      message: `TEST-BREADCRUMB-DATA: ${dateString}`,
+      data: {
+        stringTest: 'Hello',
+        numberTest: 404,
+        objectTest: {
+          foo: 'bar',
+        },
+        arrayTest: ['foo', 'bar', 400],
+        nullTest: null,
+        undefinedTest: undefined,
+      },
+      category: 'TEST-CATEGORY',
+    });
+
+    console.log('Test scope properties were set.');
+  }
+
+  public clearUser(): void {
+    Sentry.configureScope(scope => {
+      scope.setUser(null);
+    });
+  }
+
+  public clearBreadcrumbs(): void {
+    Sentry.configureScope(scope => {
+      scope.clearBreadcrumbs();
+    });
+  }
+
+  public clearTestContext(): void {
+    Sentry.configureScope(scope => {
+      scope.setContext('TEST-CONTEXT', null);
+    });
+  }
 }
diff --git a/example/ionic-angular-v2/yalc.lock b/example/ionic-angular-v2/yalc.lock
index 7e10d6a..aced004 100644
--- a/example/ionic-angular-v2/yalc.lock
+++ b/example/ionic-angular-v2/yalc.lock
@@ -2,7 +2,7 @@
   "version": "v1",
   "packages": {
     "@sentry/capacitor": {
-      "signature": "58872588b4612ed22ce4359c54dbcdd0",
+      "signature": "b1a6fdd0166b2fe57dbc432bb139dbdd",
       "file": true,
       "replaced": "file:../.."
     }
diff --git a/example/ionic-angular/src/app/tab1/tab1.page.html b/example/ionic-angular/src/app/tab1/tab1.page.html
index c569fc4..ccefe2b 100644
--- a/example/ionic-angular/src/app/tab1/tab1.page.html
+++ b/example/ionic-angular/src/app/tab1/tab1.page.html
@@ -49,5 +49,22 @@
       Ignore logging an error that uses excluded type
       <ion-button (click)="ignoredTypeError()">Ignored Type Error</ion-button>
     </label>
+
+    <label>
+      Set Scope Properties
+      <ion-button (click)="setScope()">Set Scope</ion-button>
+    </label>
+    <label>
+      Clear User
+      <ion-button (click)="clearUser()">Clear User</ion-button>
+    </label>
+    <label>
+      Clear Breadcrumbs
+      <ion-button (click)="clearBreadcrumbs()">Clear Breadcrumbs</ion-button>
+    </label>
+    <label>
+      Clear Test Context
+      <ion-button (click)="clearTestContext()">Clear Test Context</ion-button>
+    </label>
   </section>
 </ion-content>
diff --git a/example/ionic-angular/src/app/tab1/tab1.page.ts b/example/ionic-angular/src/app/tab1/tab1.page.ts
index aae7f6c..4035266 100644
--- a/example/ionic-angular/src/app/tab1/tab1.page.ts
+++ b/example/ionic-angular/src/app/tab1/tab1.page.ts
@@ -44,4 +44,101 @@ export class Tab1Page {
       new RangeError('Exception that will be ignored because of its type'),
     );
   }
+
+  public setScope(): void {
+    const dateString = new Date().toString();
+
+    Sentry.setUser({
+      id: 'test-id-0',
+      email: 'testing@testing.test',
+      username: 'USER-TEST',
+      specialField: 'special user field',
+      specialFieldNumber: 418,
+    });
+
+    Sentry.setTag('SINGLE-TAG', dateString);
+    // @ts-ignore
+    Sentry.setTag('SINGLE-TAG-NUMBER', 100);
+    Sentry.setTags({
+      'MULTI-TAG-0': dateString,
+      'MULTI-TAG-1': dateString,
+      'MULTI-TAG-2': dateString,
+    });
+
+    Sentry.setExtra('SINGLE-EXTRA', dateString);
+    Sentry.setExtra('SINGLE-EXTRA-NUMBER', 100);
+    Sentry.setExtra('SINGLE-EXTRA-OBJECT', {
+      message: 'I am a teapot',
+      status: 418,
+      array: ['boo', 100, 400, { objectInsideArray: 'foobar' }],
+    });
+    Sentry.setExtras({
+      'MULTI-EXTRA-0': dateString,
+      'MULTI-EXTRA-1': dateString,
+      'MULTI-EXTRA-2': dateString,
+    });
+
+    Sentry.setContext('TEST-CONTEXT', {
+      stringTest: 'Hello',
+      numberTest: 404,
+      objectTest: {
+        foo: 'bar',
+      },
+      arrayTest: ['foo', 'bar', 400],
+      nullTest: null,
+      undefinedTest: undefined,
+    });
+
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Info,
+      message: `TEST-BREADCRUMB-INFO: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Debug,
+      message: `TEST-BREADCRUMB-DEBUG: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Error,
+      message: `TEST-BREADCRUMB-ERROR: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Fatal,
+      message: `TEST-BREADCRUMB-FATAL: ${dateString}`,
+    });
+    Sentry.addBreadcrumb({
+      level: Sentry.Severity.Info,
+      message: `TEST-BREADCRUMB-DATA: ${dateString}`,
+      data: {
+        stringTest: 'Hello',
+        numberTest: 404,
+        objectTest: {
+          foo: 'bar',
+        },
+        arrayTest: ['foo', 'bar', 400],
+        nullTest: null,
+        undefinedTest: undefined,
+      },
+      category: 'TEST-CATEGORY',
+    });
+
+    console.log('Test scope properties were set.');
+  }
+
+  public clearUser(): void {
+    Sentry.configureScope(scope => {
+      scope.setUser(null);
+    });
+  }
+
+  public clearBreadcrumbs(): void {
+    Sentry.configureScope(scope => {
+      scope.clearBreadcrumbs();
+    });
+  }
+
+  public clearTestContext(): void {
+    Sentry.configureScope(scope => {
+      scope.setContext('TEST-CONTEXT', null);
+    });
+  }
 }
diff --git a/ios/Plugin/Plugin.m b/ios/Plugin/Plugin.m
index dc7c4b5..e6fe603 100644
--- a/ios/Plugin/Plugin.m
+++ b/ios/Plugin/Plugin.m
@@ -8,4 +8,10 @@ CAP_PLUGIN(SentryCapacitor, "SentryCapacitor",
            CAP_PLUGIN_METHOD(captureEnvelope, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(fetchNativeRelease, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(fetchNativeSdkInfo, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(setTag, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(setExtra, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(setUser, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(addBreadcrumb, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(clearBreadcrumbs, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(setContext, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(crash, CAPPluginReturnPromise);)
diff --git a/ios/Plugin/Plugin.swift b/ios/Plugin/Plugin.swift
index 5356201..d7e1d31 100644
--- a/ios/Plugin/Plugin.swift
+++ b/ios/Plugin/Plugin.swift
@@ -72,32 +72,6 @@ public class SentryCapacitor: CAPPlugin {
         }
     }
 
-    private func setEventOriginTag(event: Event) {
-        guard let sdk = event.sdk else {
-            return
-        }
-        if isValidSdk(sdk: sdk) {
-            switch sdk["name"] as? String {
-            case "sentry.cocoa":
-                setEventEnvironmentTag(event: event, origin: "ios", environment: "native")
-            default:
-                return
-            }
-        }
-    }
-
-    private func setEventEnvironmentTag(event: Event, origin: String, environment: String) {
-        event.tags?["event.origin"] = origin
-        event.tags?["event.environment"] = environment
-    }
-
-    private func isValidSdk(sdk: [String: Any]) -> Bool {
-        guard let name = sdk["name"] as? String else {
-            return false
-        }
-        return !name.isEmpty
-    }
-
     @objc func captureEnvelope(_ call: CAPPluginCall) {
         guard let data = call.getString("envelope")?.data(using: .utf8) else {
             print("Cannot parse the envelope data")
@@ -137,7 +111,150 @@ public class SentryCapacitor: CAPPlugin {
         ])
     }
 
+    @objc func setUser(_ call: CAPPluginCall) {
+        let defaultUserKeys = call.getObject("defaultUserKeys")
+        let otherUserKeys = call.getObject("otherUserKeys")
+
+
+        SentrySDK.configureScope { scope in
+            if (defaultUserKeys == nil && otherUserKeys == nil) {
+                scope.setUser(nil)
+            } else {
+                let user = User()
+
+
+                if let userId = defaultUserKeys?["id"] as? String {
+                    user.userId = userId
+                }
+
+                user.email = defaultUserKeys?["email"] as! String?
+                user.username = defaultUserKeys?["username"] as! String?
+                user.ipAddress = defaultUserKeys?["ip_address"] as! String?
+
+                user.data = otherUserKeys
+
+                scope.setUser(user)
+            }
+        }
+
+
+        call.resolve()
+    }
+
+    @objc func setTag(_ call: CAPPluginCall) {
+        guard let key = call.getString("key"), let value = call.getString("value") else {
+            return call.reject("Error deserializing tag")
+        }
+
+        SentrySDK.configureScope { scope in
+            scope.setTag(value: value, key: key)
+        }
+
+        call.resolve()
+    }
+
+    @objc func setExtra(_ call: CAPPluginCall) {
+        guard let key = call.getString("key") else {
+            return call.reject("Error deserializing extra")
+        }
+
+        let value = call.getString("value")
+
+        SentrySDK.configureScope { scope in
+            scope.setExtra(value: value, key: key)
+        }
+
+        call.resolve()
+    }
+    
+    @objc func setContext(_ call: CAPPluginCall) {
+        guard let key = call.getString("key") else {
+            return call.reject("Error deserializing context")
+        }
+        
+        SentrySDK.configureScope { scope in
+            scope.setContext(value: call.getObject("value") ?? [:], key: key)
+        }
+    }
+
+    @objc func addBreadcrumb(_ call: CAPPluginCall) {
+        SentrySDK.configureScope { scope in
+            let breadcrumb = Breadcrumb()
+        
+            if let timestamp = call.getDouble("timestamp") {
+                breadcrumb.timestamp = Date(timeIntervalSince1970: timestamp)
+            }
+            
+            if let level = call.getString("level") {
+                breadcrumb.level = self.processLevel(level)
+            }
+            
+            if let category = call.getString("category") {
+                breadcrumb.category = category
+            }
+           
+            breadcrumb.type = call.getString("type")
+            breadcrumb.message = call.getString("message")
+            breadcrumb.data = call.getObject("data")
+            
+            scope.add(breadcrumb)
+        }
+
+        call.resolve()
+    }
+    
+    @objc func clearBreadcrumbs(_ call: CAPPluginCall) {
+        SentrySDK.configureScope { scope in
+            scope.clearBreadcrumbs()
+        }
+        
+        call.resolve()
+    }
+
     @objc func crash(_ call: CAPPluginCall) {
         SentrySDK.crash()
     }
+    
+    private func processLevel(_ levelString: String) -> SentryLevel {
+        switch levelString {
+        case "fatal":
+            return SentryLevel.fatal
+        case "warning":
+            return SentryLevel.warning
+        case "debug":
+            return SentryLevel.debug
+        case "error":
+            return SentryLevel.error
+        case "info":
+            return SentryLevel.info
+        default:
+            return SentryLevel.info
+        }
+    }
+    
+    private func setEventOriginTag(event: Event) {
+        guard let sdk = event.sdk else {
+            return
+        }
+        if isValidSdk(sdk: sdk) {
+            switch sdk["name"] as? String {
+            case "sentry.cocoa":
+                setEventEnvironmentTag(event: event, origin: "ios", environment: "native")
+            default:
+                return
+            }
+        }
+    }
+
+    private func setEventEnvironmentTag(event: Event, origin: String, environment: String) {
+        event.tags?["event.origin"] = origin
+        event.tags?["event.environment"] = environment
+    }
+
+    private func isValidSdk(sdk: [String: Any]) -> Bool {
+        guard let name = sdk["name"] as? String else {
+            return false
+        }
+        return !name.isEmpty
+    }
 }
diff --git a/src/definitions.ts b/src/definitions.ts
index ea040e7..524204a 100644
--- a/src/definitions.ts
+++ b/src/definitions.ts
@@ -28,10 +28,11 @@ export interface ISentryCapacitorPlugin {
   fetchNativeSdkInfo(): Promise<Package>;
   getStringBytesLength(payload: { string: string }): Promise<{ value: number }>;
   initNativeSdk(payload: { options: CapacitorOptions }): Promise<boolean>;
-  setUser(
-    user: serializedObject | null,
-    otherUserKeys: serializedObject | null,
-  ): void;
-  setTag(key: string, value: string): void;
-  setExtra(key: string, value: string): void;
+  setUser(payload: {
+    defaultUserKeys: serializedObject | null;
+    otherUserKeys: serializedObject | null;
+  }): void;
+  setTag(payload: { key: string; value: string }): void;
+  setExtra(payload: { key: string; value: string }): void;
+  setContext(payload: { key: string; value: serializedObject | null }): void;
 }
diff --git a/src/scope.ts b/src/scope.ts
index 4e32c57..55b5008 100644
--- a/src/scope.ts
+++ b/src/scope.ts
@@ -54,6 +54,17 @@ export class CapacitorScope extends Scope {
     return super.setExtra(key, extra);
   }
 
+  /**
+   * @inheritDoc
+   */
+  public setContext(
+    key: string,
+    context: { [key: string]: unknown } | null,
+  ): this {
+    NATIVE.setContext(key, context);
+    return super.setContext(key, context);
+  }
+
   /**
    * @inheritDoc
    */
diff --git a/src/wrapper.ts b/src/wrapper.ts
index 4f586aa..0481d93 100644
--- a/src/wrapper.ts
+++ b/src/wrapper.ts
@@ -1,7 +1,7 @@
 /* eslint-disable max-lines */
 import { Capacitor } from '@capacitor/core';
 import { Breadcrumb, Event, Response, Severity, User } from '@sentry/types';
-import { logger, SentryError } from '@sentry/utils';
+import { dropUndefinedKeys, logger, SentryError } from '@sentry/utils';
 
 import { CapacitorOptions } from './options';
 import { SentryCapacitor } from './plugin';
@@ -146,7 +146,7 @@ export const NATIVE = {
    * @param value string
    */
   setUser(user: User | null): void {
-    if (!this.enableNative || this.platform === 'ios') {
+    if (!this.enableNative) {
       return;
     }
     if (!this.isNativeClientAvailable()) {
@@ -158,16 +158,18 @@ export const NATIVE = {
     let otherUserKeys = null;
     if (user) {
       const { id, ip_address, email, username, ...otherKeys } = user;
-      defaultUserKeys = this._serializeObject({
-        email,
-        id,
-        ip_address,
-        username,
-      });
-      otherUserKeys = this._serializeObject(otherKeys);
+      defaultUserKeys = dropUndefinedKeys(
+        this._serializeObject({
+          email,
+          id,
+          ip_address,
+          username,
+        }),
+      );
+      otherUserKeys = dropUndefinedKeys(this._serializeObject(otherKeys));
     }
 
-    SentryCapacitor.setUser(defaultUserKeys, otherUserKeys);
+    SentryCapacitor.setUser({ defaultUserKeys, otherUserKeys });
   },
 
   /**
@@ -176,7 +178,7 @@ export const NATIVE = {
    * @param value string
    */
   setTag(key: string, value: string): void {
-    if (!this.enableNative || this.platform === 'ios') {
+    if (!this.enableNative) {
       return;
     }
     if (!this.isNativeClientAvailable()) {
@@ -186,7 +188,7 @@ export const NATIVE = {
     const stringifiedValue =
       typeof value === 'string' ? value : JSON.stringify(value);
 
-    SentryCapacitor.setTag(key, stringifiedValue);
+    SentryCapacitor.setTag({ key, value: stringifiedValue });
   },
 
   /**
@@ -196,7 +198,7 @@ export const NATIVE = {
    * @param extra any
    */
   setExtra(key: string, extra: unknown): void {
-    if (!this.enableNative || this.platform === 'ios') {
+    if (!this.enableNative) {
       return;
     }
     if (!this.isNativeClientAvailable()) {
@@ -204,11 +206,11 @@ export const NATIVE = {
     }
 
     // we stringify the extra as native only takes in strings.
-    const stringifiedExtra =
+    const stringifiedValue =
       typeof extra === 'string' ? extra : JSON.stringify(extra);
 
     // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
-    SentryCapacitor.setExtra(key, stringifiedExtra);
+    SentryCapacitor.setExtra({ key, value: stringifiedValue });
   },
 
   /**
@@ -216,7 +218,7 @@ export const NATIVE = {
    * @param breadcrumb Breadcrumb
    */
   addBreadcrumb(breadcrumb: Breadcrumb): void {
-    if (!this.enableNative || this.platform === 'ios') {
+    if (!this.enableNative) {
       return;
     }
     if (!this.isNativeClientAvailable()) {
@@ -239,7 +241,7 @@ export const NATIVE = {
    * Clears breadcrumbs on the native scope.
    */
   clearBreadcrumbs(): void {
-    if (!this.enableNative || this.platform === 'ios') {
+    if (!this.enableNative) {
       return;
     }
     if (!this.isNativeClientAvailable()) {
@@ -249,6 +251,31 @@ export const NATIVE = {
     SentryCapacitor.clearBreadcrumbs();
   },
 
+  /**
+   * Sets context on the native scope. Not implemented in Android yet.
+   * @param key string
+   * @param context key-value map
+   */
+  setContext(key: string, context: { [key: string]: unknown } | null): void {
+    if (!this.enableNative) {
+      return;
+    }
+    if (!this.isNativeClientAvailable()) {
+      throw this._NativeClientError;
+    }
+
+    if (this.platform === 'android') {
+      // setContext not available on the Android SDK yet.
+      this.setExtra(key, context);
+    } else {
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+      SentryCapacitor.setContext({
+        key,
+        value: context !== null ? this._serializeObject(context) : null,
+      });
+    }
+  },
+
   /**
    * Serializes all values of root-level keys into strings.
    * @param data key-value map.


Test patch:
diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts
index 4603830..d5efe39 100644
--- a/test/wrapper.test.ts
+++ b/test/wrapper.test.ts
@@ -35,11 +35,18 @@ jest.mock('../src/plugin', () => {
           version: '0.0.1',
         }),
       ),
-      getStringBytesLength: jest.fn(() => Promise.resolve({ value: 1 })),
+      getStringBytesLength: jest.fn(() =>
+        Promise.resolve({
+          value: 1,
+        }),
+      ),
+      initNativeSdk: jest.fn(options => Promise.resolve(options)),
+      setContext: jest.fn(() => Promise.resolve()),
+      setExtra: jest.fn(() => Promise.resolve()),
+      setTag: jest.fn(() => Promise.resolve()),
       setUser: jest.fn(() => {
         return;
       }),
-      initNativeSdk: jest.fn(options => Promise.resolve(options)),
     },
   };
 });
@@ -48,6 +55,7 @@ import { SentryCapacitor } from '../src/plugin';
 
 beforeEach(() => {
   NATIVE.enableNative = true;
+  NATIVE.platform = 'android';
 });
 
 afterEach(() => {
@@ -178,15 +186,15 @@ describe('Tests Native Wrapper', () => {
         unique: '123',
       });
 
-      expect(SentryCapacitor.setUser).toBeCalledWith(
-        {
+      expect(SentryCapacitor.setUser).toBeCalledWith({
+        defaultUserKeys: {
           email: 'hello@sentry.io',
           id: '3.1234587',
         },
-        {
+        otherUserKeys: {
           unique: '123',
         },
-      );
+      });
     });
 
     test('calls native setUser with empty object as second param if no unique keys', async () => {
@@ -194,12 +202,89 @@ describe('Tests Native Wrapper', () => {
         id: 'Hello',
       });
 
-      expect(SentryCapacitor.setUser).toBeCalledWith(
-        {
+      expect(SentryCapacitor.setUser).toBeCalledWith({
+        defaultUserKeys: {
           id: 'Hello',
         },
-        {},
-      );
+        otherUserKeys: {},
+      });
+    });
+  });
+
+  describe('setTag', () => {
+    it('calls setTag', () => {
+      NATIVE.setTag('key', 'value');
+
+      expect(SentryCapacitor.setTag).toBeCalledWith({
+        key: 'key',
+        value: 'value',
+      });
+    });
+
+    it('serializes number', () => {
+      // @ts-ignore test number
+      NATIVE.setTag('key', 0);
+
+      expect(SentryCapacitor.setTag).toBeCalledWith({
+        key: 'key',
+        value: '0',
+      });
+    });
+
+    it('serializes object', () => {
+      // @ts-ignore test number
+      NATIVE.setTag('key', {});
+
+      expect(SentryCapacitor.setTag).toBeCalledWith({
+        key: 'key',
+        value: '{}',
+      });
+    });
+  });
+
+  describe('setExtra', () => {
+    it('calls setExtra', () => {
+      NATIVE.setExtra('key', { hello: 'world' });
+
+      expect(SentryCapacitor.setExtra).toBeCalledWith({
+        key: 'key',
+        value: '{"hello":"world"}',
+      });
+    });
+  });
+
+  describe('setContext', () => {
+    it('calls setContext', () => {
+      NATIVE.platform = 'ios';
+
+      NATIVE.setContext('key', { hello: 'world' });
+
+      expect(SentryCapacitor.setContext).toBeCalledWith({
+        key: 'key',
+        value: { hello: 'world' },
+      });
+    });
+
+    it('calls setContext with null', () => {
+      NATIVE.platform = 'ios';
+
+      NATIVE.setContext('key', null);
+
+      expect(SentryCapacitor.setContext).toBeCalledWith({
+        key: 'key',
+        value: null,
+      });
+    });
+
+    it('calls setExtra on android', () => {
+      NATIVE.platform = 'android';
+
+      NATIVE.setContext('key', { hello: 'world' });
+
+      expect(SentryCapacitor.setExtra).toBeCalledWith({
+        key: 'key',
+        value: '{"hello":"world"}',
+      });
     });
   });