diff --git a/CHANGELOG.md b/CHANGELOG.md index 6182f3cbc7..338f92f7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased: + +* Fix: Add missing iOS contexts (#761) + ## 6.5.1 - Update event contexts (#838) diff --git a/dart/lib/src/protocol/sentry_user.dart b/dart/lib/src/protocol/sentry_user.dart index efe21e0cdd..7b8c3237e8 100644 --- a/dart/lib/src/protocol/sentry_user.dart +++ b/dart/lib/src/protocol/sentry_user.dart @@ -63,12 +63,16 @@ class SentryUser { /// Deserializes a [SentryUser] from JSON [Map]. factory SentryUser.fromJson(Map json) { + var extras = json['extras']; + if (extras != null) { + extras = Map.from(extras as Map); + } return SentryUser( id: json['id'], username: json['username'], email: json['email'], ipAddress: json['ip_address'], - extras: json['extras'], + extras: extras, ); } diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index f48ebfad7c..191f4df1f8 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -88,6 +88,31 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { var infos = ["contexts": context] + if let tags = serializedScope["tags"] as? [String: String] { + infos["tags"] = tags + } + if let extra = serializedScope["extra"] as? [String: Any] { + infos["extra"] = extra + } + if let user = serializedScope["user"] as? [String: Any] { + infos["user"] = user + } + if let dist = serializedScope["dist"] as? String { + infos["dist"] = dist + } + if let environment = serializedScope["environment"] as? String { + infos["environment"] = environment + } + if let fingerprint = serializedScope["fingerprint"] as? [String] { + infos["fingerprint"] = fingerprint + } + if let level = serializedScope["level"] as? String { + infos["level"] = level + } + if let breadcrumbs = serializedScope["breadcrumbs"] as? [[String: Any]] { + infos["breadcrumbs"] = breadcrumbs + } + if let user = serializedScope["user"] as? [String: Any] { infos["user"] = user } else { diff --git a/flutter/lib/src/default_integrations.dart b/flutter/lib/src/default_integrations.dart index 2d24feb049..3bff9cf850 100644 --- a/flutter/lib/src/default_integrations.dart +++ b/flutter/lib/src/default_integrations.dart @@ -175,9 +175,10 @@ class _LoadContextsIntegrationEventProcessor extends EventProcessor { final infos = Map.from( await (_channel.invokeMethod('loadContexts')), ); - if (infos['contexts'] != null) { + final contextsMap = infos['contexts'] as Map?; + if (contextsMap != null && contextsMap.isNotEmpty) { final contexts = Contexts.fromJson( - Map.from(infos['contexts'] as Map), + Map.from(contextsMap), ); final eventContexts = event.contexts.clone(); @@ -195,14 +196,87 @@ class _LoadContextsIntegrationEventProcessor extends EventProcessor { event = event.copyWith(contexts: eventContexts); } - final userMap = infos['user']; - if (event.user == null && userMap != null) { - final user = Map.from(userMap as Map); + final tagsMap = infos['tags'] as Map?; + if (tagsMap != null && tagsMap.isNotEmpty) { + final tags = event.tags ?? {}; + final newTags = Map.from(tagsMap); + + for (final tag in newTags.entries) { + if (!tags.containsKey(tag.key)) { + tags[tag.key] = tag.value; + } + } + event = event.copyWith(tags: tags); + } + + final extraMap = infos['extra'] as Map?; + if (extraMap != null && extraMap.isNotEmpty) { + final extras = event.extra ?? {}; + final newExtras = Map.from(extraMap); + + for (final extra in newExtras.entries) { + if (!extras.containsKey(extra.key)) { + extras[extra.key] = extra.value; + } + } + event = event.copyWith(extra: extras); + } + + final userMap = infos['user'] as Map?; + if (event.user == null && userMap != null && userMap.isNotEmpty) { + final user = Map.from(userMap); event = event.copyWith(user: SentryUser.fromJson(user)); } - if (infos['integrations'] != null) { - final integrations = List.from(infos['integrations'] as List); + final distString = infos['dist'] as String?; + if (event.dist == null && distString != null) { + event = event.copyWith(dist: distString); + } + + final environmentString = infos['environment'] as String?; + if (event.environment == null && environmentString != null) { + event = event.copyWith(environment: environmentString); + } + + final fingerprintList = infos['fingerprint'] as List?; + if (fingerprintList != null && fingerprintList.isNotEmpty) { + final eventFingerprints = event.fingerprint ?? []; + final newFingerprint = List.from(fingerprintList); + + for (final fingerprint in newFingerprint) { + if (!eventFingerprints.contains(fingerprint)) { + eventFingerprints.add(fingerprint); + } + } + event = event.copyWith(fingerprint: eventFingerprints); + } + + final levelString = infos['level'] as String?; + if (event.level == null && levelString != null) { + event = event.copyWith(level: SentryLevel.fromName(levelString)); + } + + final breadcrumbsList = infos['breadcrumbs'] as List?; + if (breadcrumbsList != null && breadcrumbsList.isNotEmpty) { + final breadcrumbs = event.breadcrumbs ?? []; + final newBreadcrumbs = List.from(breadcrumbsList); + + for (final breadcrumb in newBreadcrumbs) { + final newBreadcrumb = Map.from(breadcrumb); + final crumb = Breadcrumb.fromJson(newBreadcrumb); + breadcrumbs.add(crumb); + } + + breadcrumbs.sort((a, b) { + return a.timestamp.compareTo(b.timestamp); + }); + + event = event.copyWith(breadcrumbs: breadcrumbs); + } + + final integrationsList = infos['integrations'] as List?; + if (integrationsList != null && integrationsList.isNotEmpty) { + final integrations = List.from(integrationsList); final sdk = event.sdk ?? _options.sdk; for (final integration in integrations) { @@ -214,8 +288,9 @@ class _LoadContextsIntegrationEventProcessor extends EventProcessor { event = event.copyWith(sdk: sdk); } - if (infos['package'] != null) { - final package = Map.from(infos['package'] as Map); + final packageMap = infos['package'] as Map?; + if (packageMap != null && packageMap.isNotEmpty) { + final package = Map.from(packageMap); final sdk = event.sdk ?? _options.sdk; final name = package['sdk_name']; diff --git a/flutter/test/load_contexts_integrations_test.dart b/flutter/test/load_contexts_integrations_test.dart index d19cd9bcab..d86490420d 100644 --- a/flutter/test/load_contexts_integrations_test.dart +++ b/flutter/test/load_contexts_integrations_test.dart @@ -28,6 +28,13 @@ void main() { SentryEvent getEvent( {SdkVersion? sdk, Map? tags, + Map? extra, + SentryUser? user, + String? dist, + String? environment, + List? fingerprint, + SentryLevel? level, + List? breadcrumbs, List integrations = const ['EventIntegration'], List packages = const [ SentryPackage('event-package', '2.0') @@ -39,6 +46,13 @@ void main() { packages: packages, ), tags: tags, + extra: extra, + user: user, + dist: dist, + environment: environment, + fingerprint: fingerprint, + level: level, + breadcrumbs: breadcrumbs, ); } @@ -265,6 +279,143 @@ void main() { expect(event?.tags?.containsKey('event.environment'), false); }, ); + + test('should merge in tags from native without overriding flutter keys', + () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(tags: {'key': 'flutter', 'key-a': 'flutter'}); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.tags?['key'], 'flutter'); + expect(event?.tags?['key-a'], 'flutter'); + expect(event?.tags?['key-b'], 'native'); + }); + + test('should merge in extra from native without overriding flutter keys', + () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(extra: {'key': 'flutter', 'key-a': 'flutter'}); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.extra?['key'], 'flutter'); + expect(event?.extra?['key-a'], 'flutter'); + expect(event?.extra?['key-b'], 'native'); + }); + + test('should set user from native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.user?.id, '196E065A-AAF7-409A-9A6C-A81F40274CB9'); + expect(event?.user?.username, 'fixture-username'); + expect(event?.user?.email, 'fixture-email'); + expect(event?.user?.ipAddress, 'fixture-ip_address'); + expect(event?.user?.extras?['key'], 'value'); + }); + + test('should not override user with native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(user: SentryUser(id: 'abc')); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.user?.id, 'abc'); + }); + + test('should set dist from native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.dist, 'fixture-dist'); + }); + + test('should not override dist with native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(dist: 'abc'); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.dist, 'abc'); + }); + + test('should set environment from native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.environment, 'fixture-environment'); + }); + + test('should not override environment with native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(environment: 'abc'); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.environment, 'abc'); + }); + + test('should merge in fingerprint from native without duplicating entries', + () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(fingerprint: ['fingerprint-a', 'fingerprint-b']); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.fingerprint, ['fingerprint-a', 'fingerprint-b']); + }); + + test('should set level from native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.level, SentryLevel.error); + }); + + test('should not override level with native', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final e = getEvent(level: SentryLevel.fatal); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.level, SentryLevel.fatal); + }); + + test('should merge in breadcrumbs sorted by timestamp', () async { + final integration = fixture.getSut(); + integration(fixture.hub, fixture.options); + + final breadcrumb = Breadcrumb( + message: 'flutter-crumb', + timestamp: DateTime.fromMillisecondsSinceEpoch(1), + ); + final e = getEvent(breadcrumbs: [breadcrumb]); + final event = await fixture.options.eventProcessors.first.apply(e); + + expect(event?.breadcrumbs?.length, 2); + expect(event?.breadcrumbs?[0].message, 'native-crumb'); + expect(event?.breadcrumbs?[1].message, 'flutter-crumb'); + }); } class Fixture { @@ -288,7 +439,25 @@ class Fixture { 'runtime': {'name': 'RT1'}, 'theme': 'material', }, - 'user': {'id': '196E065A-AAF7-409A-9A6C-A81F40274CB9'} + 'user': { + 'id': '196E065A-AAF7-409A-9A6C-A81F40274CB9', + 'username': 'fixture-username', + 'email': 'fixture-email', + 'ip_address': 'fixture-ip_address', + 'extras': {'key': 'value'}, + }, + 'tags': {'key-a': 'native', 'key-b': 'native'}, + 'extra': {'key-a': 'native', 'key-b': 'native'}, + 'dist': 'fixture-dist', + 'environment': 'fixture-environment', + 'fingerprint': ['fingerprint-a'], + 'level': 'error', + 'breadcrumbs': [ + { + 'timestamp': '1970-01-01T00:00:00.000Z', + 'message': 'native-crumb', + } + ] }}) { channel.setMockMethodCallHandler((MethodCall methodCall) async { called = true;