Skip to content

Commit 3aacb23

Browse files
vaindbuenaflorgetsentry-botgetsentry-bot
authored
feat: iOS replay support (#2209)
* minor gradle fixes * tmp: local sentry-java build * tmp: use relative path to sentry-java * tmp: local java build patches * replay options * replay recorder * wip: JNI native bindings * use compatible jnigen * add missing gradlew to flutter/android * replay recorder JNI binding code * replay recorder binding jni code * jni 0.6 * wip: android jni replay * replay binding * glue code for jni * chore: update to cocoa 8.24.1-alpha.0 * wip: cocoa integration * wip: ios replay * cleanup * formatting * android fixes * move native setup to the native sdk integration * cleanup & improvements * improve widget filter and implement redact options * fix image scaling * ktlint format * ci fixes * fix tests * add jnigen scripts * use android 7.9.0 alpha.1 * move native init & close to SentryNative * cleanup * add macOS integration link * rollback cocoa changes * remove jni/jnigen * wip: methodchannel based android recorder * callback * linter issues * minor fixes * more fixes * linter issues * cleanup * improve logging * move replay to experimental, same as in other SDKs * improve tree shaking * test: scheduler * support browser test * fix compat with old flutter * cleanup * rename recorder_widget_filter.dart * fixup scheduler test * improve test coverage * pr cleanup * test: widget filter * cleanup * test widget filter visibility * cleanup * always add screenshot widget * recorder test * cleanup * limit recorder test to vm * wip: integration test * cleanup * ktlint format * detekt suppression * ktlint format * improve scheduler stop behavior * wip: error replay mapping * suppress detekt TooGenericExceptionThrown * Update flutter/lib/src/replay/recorder.dart Co-authored-by: Giancarlo Buenaflor <[email protected]> * Update flutter/lib/src/native/java/sentry_native_java.dart Co-authored-by: Giancarlo Buenaflor <[email protected]> * improve comments * feat: associate dart errors with replays (#2070) * feat: associate dart errors with replays * ktlint * cleanup * tests * chote: remove path dependency * wip: ios replay * fix result callback * iOS related refactorings * logs * fix tests * call captureReplay on iOS & set * ios replay breadcrumbs * feat: replay breadcrumbs (android) (#2163) * feat: replay breadcrumbs * ktlint format * fixup tests * cleanup * linter issues * detekt linter issue * move touch path build to dart to deduplicate * fix metrics app compilation * linter issue * test: native replay integration binding (#2189) * wip: test native integration * test: native replay binding * update example * chore: update pubspec * fixup tests * Update flutter/test/mocks.dart * chore: update changelog * fix publishing * release: 8.6.0-alpha.2 * cleanup * fix macos compilation * test: iOS support * linter issues * linter issues * chore: update changelog * Update flutter/lib/src/native/cocoa/sentry_native_cocoa.dart Co-authored-by: Giancarlo Buenaflor <[email protected]> --------- Co-authored-by: Giancarlo Buenaflor <[email protected]> Co-authored-by: getsentry-bot <[email protected]> Co-authored-by: getsentry-bot <[email protected]>
1 parent b92d907 commit 3aacb23

17 files changed

+593
-182
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209))
8+
39
## 8.6.0
410

511
### Improvements

flutter/ios/Classes/SentryFlutter.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ public final class SentryFlutter {
7070
if let appHangTimeoutIntervalMillis = data["appHangTimeoutIntervalMillis"] as? NSNumber {
7171
options.appHangTimeoutInterval = appHangTimeoutIntervalMillis.doubleValue / 1000
7272
}
73+
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
74+
if let replayOptions = data["replay"] as? [String: Any] {
75+
options.experimental.sessionReplay.sessionSampleRate =
76+
(replayOptions["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0
77+
options.experimental.sessionReplay.errorSampleRate =
78+
(replayOptions["errorSampleRate"] as? NSNumber)?.floatValue ?? 0
79+
}
80+
#endif
7381
}
7482

7583
private func logLevelFrom(diagnosticLevel: String) -> SentryLevel {

flutter/ios/Classes/SentryFlutterPluginApple.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import CoreVideo
1212

1313
// swiftlint:disable:next type_body_length
1414
public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
15+
private let channel: FlutterMethodChannel
1516

1617
private static let nativeClientName = "sentry.cocoa.flutter"
1718

@@ -38,12 +39,16 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
3839
let channel = FlutterMethodChannel(name: "sentry_flutter", binaryMessenger: registrar.messenger)
3940
#endif
4041

41-
let instance = SentryFlutterPluginApple()
42+
let instance = SentryFlutterPluginApple(channel: channel)
4243
instance.registerObserver()
43-
4444
registrar.addMethodCallDelegate(instance, channel: channel)
4545
}
4646

47+
private init(channel: FlutterMethodChannel) {
48+
self.channel = channel
49+
super.init()
50+
}
51+
4752
private lazy var sentryFlutter = SentryFlutter()
4853

4954
private func registerObserver() {
@@ -174,6 +179,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
174179
case "resumeAppHangTracking":
175180
resumeAppHangTracking(result)
176181

182+
case "sendReplayForEvent":
183+
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
184+
PrivateSentrySDKOnly.captureReplay()
185+
result(PrivateSentrySDKOnly.getReplayId())
186+
#else
187+
result(nil)
188+
#endif
189+
177190
default:
178191
result(FlutterMethodNotImplemented)
179192
}
@@ -323,6 +336,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
323336
didReceiveDidBecomeActiveNotification = false
324337
}
325338

339+
#if canImport(UIKit) && !SENTRY_NO_UIKIT
340+
#if os(iOS) || os(tvOS)
341+
let breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter()
342+
let screenshotProvider = SentryFlutterReplayScreenshotProvider(channel: self.channel)
343+
PrivateSentrySDKOnly.configureSessionReplay(with: breadcrumbConverter, screenshotProvider: screenshotProvider)
344+
#endif
345+
#endif
346+
326347
result("")
327348
}
328349

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@import Sentry;
2+
3+
#if SENTRY_TARGET_REPLAY_SUPPORTED
4+
@class SentryRRWebEvent;
5+
6+
@interface SentryFlutterReplayBreadcrumbConverter
7+
: NSObject <SentryReplayBreadcrumbConverter>
8+
9+
- (instancetype _Nonnull)init;
10+
11+
- (id<SentryRRWebEvent> _Nullable)convertFrom:
12+
(SentryBreadcrumb *_Nonnull)breadcrumb;
13+
14+
@end
15+
#endif
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#import "SentryFlutterReplayBreadcrumbConverter.h"
2+
3+
@import Sentry;
4+
5+
#if SENTRY_TARGET_REPLAY_SUPPORTED
6+
7+
@implementation SentryFlutterReplayBreadcrumbConverter {
8+
SentrySRDefaultBreadcrumbConverter *defaultConverter;
9+
}
10+
11+
- (instancetype _Nonnull)init {
12+
if (self = [super init]) {
13+
self->defaultConverter =
14+
[SentrySessionReplayIntegration createDefaultBreadcrumbConverter];
15+
}
16+
return self;
17+
}
18+
19+
- (id<SentryRRWebEvent> _Nullable)convertFrom:
20+
(SentryBreadcrumb *_Nonnull)breadcrumb {
21+
assert(breadcrumb.timestamp != nil);
22+
23+
if (breadcrumb.category == nil
24+
// Do not add Sentry Event breadcrumbs to replay
25+
|| [breadcrumb.category isEqualToString:@"sentry.event"] ||
26+
[breadcrumb.category isEqualToString:@"sentry.transaction"]) {
27+
return nil;
28+
}
29+
30+
if ([breadcrumb.category isEqualToString:@"http"]) {
31+
return [self convertNetwork:breadcrumb];
32+
}
33+
34+
if ([breadcrumb.category isEqualToString:@"navigation"]) {
35+
return [self convertFrom:breadcrumb withCategory:nil andMessage:nil];
36+
}
37+
38+
if ([breadcrumb.category isEqualToString:@"ui.click"]) {
39+
return [self convertFrom:breadcrumb
40+
withCategory:@"ui.tap"
41+
andMessage:breadcrumb.data[@"path"]];
42+
}
43+
44+
SentryRRWebEvent *nativeBreadcrumb =
45+
[self->defaultConverter convertFrom:breadcrumb];
46+
47+
// ignore native navigation breadcrumbs
48+
if (nativeBreadcrumb && nativeBreadcrumb.data &&
49+
nativeBreadcrumb.data[@"payload"] &&
50+
nativeBreadcrumb.data[@"payload"][@"category"] &&
51+
[nativeBreadcrumb.data[@"payload"][@"category"]
52+
isEqualToString:@"navigation"]) {
53+
return nil;
54+
}
55+
56+
return nativeBreadcrumb;
57+
}
58+
59+
- (id<SentryRRWebEvent> _Nullable)convertFrom:
60+
(SentryBreadcrumb *_Nonnull)breadcrumb
61+
withCategory:(NSString *)category
62+
andMessage:(NSString *)message {
63+
return [SentrySessionReplayIntegration
64+
createBreadcrumbwithTimestamp:breadcrumb.timestamp
65+
category:category ?: breadcrumb.category
66+
message:message ?: breadcrumb.message
67+
level:breadcrumb.level
68+
data:breadcrumb.data];
69+
}
70+
71+
- (id<SentryRRWebEvent> _Nullable)convertNetwork:
72+
(SentryBreadcrumb *_Nonnull)breadcrumb {
73+
NSNumber *startTimestamp =
74+
[breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]]
75+
? breadcrumb.data[@"start_timestamp"]
76+
: nil;
77+
NSNumber *endTimestamp =
78+
[breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]]
79+
? breadcrumb.data[@"end_timestamp"]
80+
: nil;
81+
NSString *url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]]
82+
? breadcrumb.data[@"url"]
83+
: nil;
84+
85+
if (startTimestamp == nil || endTimestamp == nil || url == nil) {
86+
return nil;
87+
}
88+
89+
NSMutableDictionary *data = [[NSMutableDictionary alloc] init];
90+
if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) {
91+
data[@"method"] = breadcrumb.data[@"method"];
92+
}
93+
if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) {
94+
data[@"statusCode"] = breadcrumb.data[@"status_code"];
95+
}
96+
if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) {
97+
data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"];
98+
}
99+
if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) {
100+
data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"];
101+
}
102+
103+
return [SentrySessionReplayIntegration
104+
createNetworkBreadcrumbWithTimestamp:[self dateFrom:startTimestamp]
105+
endTimestamp:[self dateFrom:endTimestamp]
106+
operation:@"resource.http"
107+
description:url
108+
data:data];
109+
}
110+
111+
- (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp {
112+
return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)];
113+
}
114+
115+
@end
116+
117+
#endif
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
@import Sentry;
2+
3+
#if SENTRY_TARGET_REPLAY_SUPPORTED
4+
@class SentryRRWebEvent;
5+
6+
@interface SentryFlutterReplayScreenshotProvider
7+
: NSObject <SentryViewScreenshotProvider>
8+
9+
- (instancetype)initWithChannel:(id)FlutterMethodChannel;
10+
11+
@end
12+
#endif
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@import Sentry;
2+
3+
#if SENTRY_TARGET_REPLAY_SUPPORTED
4+
#import "SentryFlutterReplayScreenshotProvider.h"
5+
#import <Flutter/Flutter.h>
6+
7+
@implementation SentryFlutterReplayScreenshotProvider {
8+
FlutterMethodChannel *channel;
9+
}
10+
11+
- (instancetype _Nonnull)initWithChannel:
12+
(FlutterMethodChannel *_Nonnull)channel {
13+
if (self = [super init]) {
14+
self->channel = channel;
15+
}
16+
return self;
17+
}
18+
19+
- (void)imageWithView:(UIView *_Nonnull)view
20+
options:(id<SentryRedactOptions> _Nonnull)options
21+
onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete {
22+
[self->channel
23+
invokeMethod:@"captureReplayScreenshot"
24+
arguments:@{@"replayId" : [PrivateSentrySDKOnly getReplayId]}
25+
result:^(id value) {
26+
if (value == nil) {
27+
NSLog(@"SentryFlutterReplayScreenshotProvider received null "
28+
@"result. "
29+
@"Cannot capture a replay screenshot.");
30+
} else if ([value
31+
isKindOfClass:[FlutterStandardTypedData class]]) {
32+
FlutterStandardTypedData *typedData =
33+
(FlutterStandardTypedData *)value;
34+
UIImage *image = [UIImage imageWithData:typedData.data];
35+
onComplete(image);
36+
} else {
37+
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
38+
@"unexpected result. "
39+
@"Cannot capture a replay screenshot.");
40+
}
41+
}];
42+
}
43+
44+
@end
45+
46+
#endif

flutter/lib/src/native/cocoa/sentry_native_cocoa.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,76 @@
11
import 'dart:ffi';
2+
import 'dart:typed_data';
3+
import 'dart:ui';
24

35
import 'package:meta/meta.dart';
46

57
import '../../../sentry_flutter.dart';
8+
import '../../event_processor/replay_event_processor.dart';
9+
import '../../replay/recorder.dart';
10+
import '../../replay/recorder_config.dart';
611
import '../sentry_native_channel.dart';
712
import 'binding.dart' as cocoa;
813

914
@internal
1015
class SentryNativeCocoa extends SentryNativeChannel {
1116
late final _lib = cocoa.SentryCocoa(DynamicLibrary.process());
17+
ScreenshotRecorder? _replayRecorder;
18+
SentryId? _replayId;
1219

1320
SentryNativeCocoa(super.options, super.channel);
1421

22+
@override
23+
Future<void> init(Hub hub) async {
24+
// We only need these when replay is enabled (session or error capture)
25+
// so let's set it up conditionally. This allows Dart to trim the code.
26+
if (options.experimental.replay.isEnabled &&
27+
options.platformChecker.platform.isIOS) {
28+
// We only need the integration when error-replay capture is enabled.
29+
if ((options.experimental.replay.errorSampleRate ?? 0) > 0) {
30+
options.addEventProcessor(ReplayEventProcessor(this));
31+
}
32+
33+
channel.setMethodCallHandler((call) async {
34+
switch (call.method) {
35+
case 'captureReplayScreenshot':
36+
_replayRecorder ??=
37+
ScreenshotRecorder(ScreenshotRecorderConfig(), options);
38+
final replayId =
39+
SentryId.fromId(call.arguments['replayId'] as String);
40+
if (_replayId != replayId) {
41+
_replayId = replayId;
42+
hub.configureScope((s) {
43+
// ignore: invalid_use_of_internal_member
44+
s.replayId = replayId;
45+
});
46+
}
47+
48+
Uint8List? imageBytes;
49+
await _replayRecorder?.capture((image) async {
50+
final imageData =
51+
await image.toByteData(format: ImageByteFormat.png);
52+
if (imageData != null) {
53+
options.logger(
54+
SentryLevel.debug,
55+
'Replay: captured screenshot ('
56+
'${image.width}x${image.height} pixels, '
57+
'${imageData.lengthInBytes} bytes)');
58+
imageBytes = imageData.buffer.asUint8List();
59+
} else {
60+
options.logger(SentryLevel.warning,
61+
'Replay: failed to convert screenshot to PNG');
62+
}
63+
});
64+
return imageBytes;
65+
default:
66+
throw UnimplementedError('Method ${call.method} not implemented');
67+
}
68+
});
69+
}
70+
71+
return super.init(hub);
72+
}
73+
1574
@override
1675
int? startProfiler(SentryId traceId) => tryCatchSync('startProfiler', () {
1776
final cSentryId = cocoa.SentryId1.alloc(_lib)

0 commit comments

Comments
 (0)