Skip to content

Commit

Permalink
feat: iOS replay support (#2209)
Browse files Browse the repository at this point in the history
* 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 <giancarlo_buenaflor@yahoo.com>

* Update flutter/lib/src/native/java/sentry_native_java.dart

Co-authored-by: Giancarlo Buenaflor <giancarlo_buenaflor@yahoo.com>

* 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 <giancarlo_buenaflor@yahoo.com>

---------

Co-authored-by: Giancarlo Buenaflor <giancarlo_buenaflor@yahoo.com>
Co-authored-by: getsentry-bot <bot@sentry.io>
Co-authored-by: getsentry-bot <bot@getsentry.com>
  • Loading branch information
4 people authored Aug 7, 2024
1 parent b92d907 commit 3aacb23
Show file tree
Hide file tree
Showing 17 changed files with 593 additions and 182 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Features

- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209))

## 8.6.0

### Improvements
Expand Down
8 changes: 8 additions & 0 deletions flutter/ios/Classes/SentryFlutter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ public final class SentryFlutter {
if let appHangTimeoutIntervalMillis = data["appHangTimeoutIntervalMillis"] as? NSNumber {
options.appHangTimeoutInterval = appHangTimeoutIntervalMillis.doubleValue / 1000
}
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
if let replayOptions = data["replay"] as? [String: Any] {
options.experimental.sessionReplay.sessionSampleRate =
(replayOptions["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0
options.experimental.sessionReplay.errorSampleRate =
(replayOptions["errorSampleRate"] as? NSNumber)?.floatValue ?? 0
}
#endif
}

private func logLevelFrom(diagnosticLevel: String) -> SentryLevel {
Expand Down
25 changes: 23 additions & 2 deletions flutter/ios/Classes/SentryFlutterPluginApple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CoreVideo

// swiftlint:disable:next type_body_length
public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
private let channel: FlutterMethodChannel

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

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

let instance = SentryFlutterPluginApple()
let instance = SentryFlutterPluginApple(channel: channel)
instance.registerObserver()

registrar.addMethodCallDelegate(instance, channel: channel)
}

private init(channel: FlutterMethodChannel) {
self.channel = channel
super.init()
}

private lazy var sentryFlutter = SentryFlutter()

private func registerObserver() {
Expand Down Expand Up @@ -174,6 +179,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
case "resumeAppHangTracking":
resumeAppHangTracking(result)

case "sendReplayForEvent":
#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS))
PrivateSentrySDKOnly.captureReplay()
result(PrivateSentrySDKOnly.getReplayId())
#else
result(nil)
#endif

default:
result(FlutterMethodNotImplemented)
}
Expand Down Expand Up @@ -323,6 +336,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin {
didReceiveDidBecomeActiveNotification = false
}

#if canImport(UIKit) && !SENTRY_NO_UIKIT
#if os(iOS) || os(tvOS)
let breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter()
let screenshotProvider = SentryFlutterReplayScreenshotProvider(channel: self.channel)
PrivateSentrySDKOnly.configureSessionReplay(with: breadcrumbConverter, screenshotProvider: screenshotProvider)
#endif
#endif

result("")
}

Expand Down
15 changes: 15 additions & 0 deletions flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@import Sentry;

#if SENTRY_TARGET_REPLAY_SUPPORTED
@class SentryRRWebEvent;

@interface SentryFlutterReplayBreadcrumbConverter
: NSObject <SentryReplayBreadcrumbConverter>

- (instancetype _Nonnull)init;

- (id<SentryRRWebEvent> _Nullable)convertFrom:
(SentryBreadcrumb *_Nonnull)breadcrumb;

@end
#endif
117 changes: 117 additions & 0 deletions flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#import "SentryFlutterReplayBreadcrumbConverter.h"

@import Sentry;

#if SENTRY_TARGET_REPLAY_SUPPORTED

@implementation SentryFlutterReplayBreadcrumbConverter {
SentrySRDefaultBreadcrumbConverter *defaultConverter;
}

- (instancetype _Nonnull)init {
if (self = [super init]) {
self->defaultConverter =
[SentrySessionReplayIntegration createDefaultBreadcrumbConverter];
}
return self;
}

- (id<SentryRRWebEvent> _Nullable)convertFrom:
(SentryBreadcrumb *_Nonnull)breadcrumb {
assert(breadcrumb.timestamp != nil);

if (breadcrumb.category == nil
// Do not add Sentry Event breadcrumbs to replay
|| [breadcrumb.category isEqualToString:@"sentry.event"] ||
[breadcrumb.category isEqualToString:@"sentry.transaction"]) {
return nil;
}

if ([breadcrumb.category isEqualToString:@"http"]) {
return [self convertNetwork:breadcrumb];
}

if ([breadcrumb.category isEqualToString:@"navigation"]) {
return [self convertFrom:breadcrumb withCategory:nil andMessage:nil];
}

if ([breadcrumb.category isEqualToString:@"ui.click"]) {
return [self convertFrom:breadcrumb
withCategory:@"ui.tap"
andMessage:breadcrumb.data[@"path"]];
}

SentryRRWebEvent *nativeBreadcrumb =
[self->defaultConverter convertFrom:breadcrumb];

// ignore native navigation breadcrumbs
if (nativeBreadcrumb && nativeBreadcrumb.data &&
nativeBreadcrumb.data[@"payload"] &&
nativeBreadcrumb.data[@"payload"][@"category"] &&
[nativeBreadcrumb.data[@"payload"][@"category"]
isEqualToString:@"navigation"]) {
return nil;
}

return nativeBreadcrumb;
}

- (id<SentryRRWebEvent> _Nullable)convertFrom:
(SentryBreadcrumb *_Nonnull)breadcrumb
withCategory:(NSString *)category
andMessage:(NSString *)message {
return [SentrySessionReplayIntegration
createBreadcrumbwithTimestamp:breadcrumb.timestamp
category:category ?: breadcrumb.category
message:message ?: breadcrumb.message
level:breadcrumb.level
data:breadcrumb.data];
}

- (id<SentryRRWebEvent> _Nullable)convertNetwork:
(SentryBreadcrumb *_Nonnull)breadcrumb {
NSNumber *startTimestamp =
[breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]]
? breadcrumb.data[@"start_timestamp"]
: nil;
NSNumber *endTimestamp =
[breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]]
? breadcrumb.data[@"end_timestamp"]
: nil;
NSString *url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]]
? breadcrumb.data[@"url"]
: nil;

if (startTimestamp == nil || endTimestamp == nil || url == nil) {
return nil;
}

NSMutableDictionary *data = [[NSMutableDictionary alloc] init];
if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) {
data[@"method"] = breadcrumb.data[@"method"];
}
if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) {
data[@"statusCode"] = breadcrumb.data[@"status_code"];
}
if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) {
data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"];
}
if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) {
data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"];
}

return [SentrySessionReplayIntegration
createNetworkBreadcrumbWithTimestamp:[self dateFrom:startTimestamp]
endTimestamp:[self dateFrom:endTimestamp]
operation:@"resource.http"
description:url
data:data];
}

- (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp {
return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)];
}

@end

#endif
12 changes: 12 additions & 0 deletions flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@import Sentry;

#if SENTRY_TARGET_REPLAY_SUPPORTED
@class SentryRRWebEvent;

@interface SentryFlutterReplayScreenshotProvider
: NSObject <SentryViewScreenshotProvider>

- (instancetype)initWithChannel:(id)FlutterMethodChannel;

@end
#endif
46 changes: 46 additions & 0 deletions flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@import Sentry;

#if SENTRY_TARGET_REPLAY_SUPPORTED
#import "SentryFlutterReplayScreenshotProvider.h"
#import <Flutter/Flutter.h>

@implementation SentryFlutterReplayScreenshotProvider {
FlutterMethodChannel *channel;
}

- (instancetype _Nonnull)initWithChannel:
(FlutterMethodChannel *_Nonnull)channel {
if (self = [super init]) {
self->channel = channel;
}
return self;
}

- (void)imageWithView:(UIView *_Nonnull)view
options:(id<SentryRedactOptions> _Nonnull)options
onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete {
[self->channel
invokeMethod:@"captureReplayScreenshot"
arguments:@{@"replayId" : [PrivateSentrySDKOnly getReplayId]}
result:^(id value) {
if (value == nil) {
NSLog(@"SentryFlutterReplayScreenshotProvider received null "
@"result. "
@"Cannot capture a replay screenshot.");
} else if ([value
isKindOfClass:[FlutterStandardTypedData class]]) {
FlutterStandardTypedData *typedData =
(FlutterStandardTypedData *)value;
UIImage *image = [UIImage imageWithData:typedData.data];
onComplete(image);
} else {
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
@"unexpected result. "
@"Cannot capture a replay screenshot.");
}
}];
}

@end

#endif
59 changes: 59 additions & 0 deletions flutter/lib/src/native/cocoa/sentry_native_cocoa.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,76 @@
import 'dart:ffi';
import 'dart:typed_data';
import 'dart:ui';

import 'package:meta/meta.dart';

import '../../../sentry_flutter.dart';
import '../../event_processor/replay_event_processor.dart';
import '../../replay/recorder.dart';
import '../../replay/recorder_config.dart';
import '../sentry_native_channel.dart';
import 'binding.dart' as cocoa;

@internal
class SentryNativeCocoa extends SentryNativeChannel {
late final _lib = cocoa.SentryCocoa(DynamicLibrary.process());
ScreenshotRecorder? _replayRecorder;
SentryId? _replayId;

SentryNativeCocoa(super.options, super.channel);

@override
Future<void> init(Hub hub) async {
// We only need these when replay is enabled (session or error capture)
// so let's set it up conditionally. This allows Dart to trim the code.
if (options.experimental.replay.isEnabled &&
options.platformChecker.platform.isIOS) {
// We only need the integration when error-replay capture is enabled.
if ((options.experimental.replay.errorSampleRate ?? 0) > 0) {
options.addEventProcessor(ReplayEventProcessor(this));
}

channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'captureReplayScreenshot':
_replayRecorder ??=
ScreenshotRecorder(ScreenshotRecorderConfig(), options);
final replayId =
SentryId.fromId(call.arguments['replayId'] as String);
if (_replayId != replayId) {
_replayId = replayId;
hub.configureScope((s) {
// ignore: invalid_use_of_internal_member
s.replayId = replayId;
});
}

Uint8List? imageBytes;
await _replayRecorder?.capture((image) async {
final imageData =
await image.toByteData(format: ImageByteFormat.png);
if (imageData != null) {
options.logger(
SentryLevel.debug,
'Replay: captured screenshot ('
'${image.width}x${image.height} pixels, '
'${imageData.lengthInBytes} bytes)');
imageBytes = imageData.buffer.asUint8List();
} else {
options.logger(SentryLevel.warning,
'Replay: failed to convert screenshot to PNG');
}
});
return imageBytes;
default:
throw UnimplementedError('Method ${call.method} not implemented');
}
});
}

return super.init(hub);
}

@override
int? startProfiler(SentryId traceId) => tryCatchSync('startProfiler', () {
final cSentryId = cocoa.SentryId1.alloc(_lib)
Expand Down
Loading

0 comments on commit 3aacb23

Please sign in to comment.