Clue is a simple smart-bug report framework for iOS, which allows your users to record full bug/crash report and send it to you as a single .clue file via email.
(which includes full video of the screen, views structure, all network operations and user interactions during recording)
When you get a new bug report from your users - you're not a software engineer anymore. You become a true detective, trying to get a clue why something went wrong in your app. Especially it’s true if you’re working in the company, where you need to talk to users directly.
I believe it’s a problem. What if you can fix bug/crash without trying to reproduce it? What if you can see all required information and exact cause of the problem right away, without wasting your time figuring it out?
Clue.framework records all required information so you’ll be able to fix bug or crash really fast. Just import and setup Clue.framework in your iOS application (Xcode project) and you'd be able to shake the device (or simulator) to start bug report recording. During that recording you can do whatever you want to reproduce the bug.
After you’re done you need to shake the device (or simulator) once again (or tap on recording indicator view) and Clue will save the .clue file with the following information:
- Device Information
- All network operations during recording
- All views structure changes (including view’s properties and subviews)
- All user touches and interactions
- Screen record video
Next Clue will open system mail window with your email (unfortunately on device only, simulator doesn’t support system mail client) — so the user can send .clue report file right to your inbox.
If you prefer not to use dependency managers, you can integrate Clue into your project manually.
- Clone the repo
git clone [email protected]:Geek-1001/Clue.git
- Open Clue.xcodeproj with Xcode
- Choose
Clue
build schema and build it with Xcode - Drag and Drop Clue.framework file from Product folder right into your Xcode project
- Make sure to select "Copy item if needed" in the dialog
- Go to Project settings > Build Phases
- Expand "Copy Files" section, choose "Frameworks" in the Destination dropdown
- Click on the plus icon and add Clue.framework
- To integrate Clue into your Xcode project using CocoaPods, add following line to your Podfile :
pod 'Clue', '~> 0.1.0'
- Then, run install command:
$ pod install
- Import Clue framework wherever you need it
Objective-C :
@import Clue
Swift :
import Clue
- To setup Clue you need to enable it with launch configuration in your AppDelegate.
Objective-C :
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[[ClueController sharedInstance] enableWithOptions:[CLUOptions optionsWithEmail:@"[email protected]"]];
return YES;
}
Swift :
optional func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
ClueController.sharedInstance().enable(with: CLUOptions(email: "[email protected]"))
return true
}
- Then you should handle shake gesture in
AppDelegate
(if you want to record bug reports from anywhere in the app on iOS 8, 9, 10) or in specificUIViewController
(sincemotionBegan
method doesn’t work inAppDelegate
starting from iOS 10).
Objective-C :
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event {
[[ClueController sharedInstance] handleShake:motion];
}
Swift :
func motionBegan(_ motion: UIEventSubtype, with event: UIEvent?) {
ClueController.sharedInstance().handleShake(motion)
}
After that you can shake your device and start recording. Shake it again to stop it.
- If you want to use your own custom UI element to enable report recording you can call
startRecording()
andstopRecording()
methods fromClueController
directly like in the example below.
Objective-C :
[[ClueController sharedInstance] startRecording];
...
[[ClueController sharedInstance] stopRecording];
Swift :
ClueController.sharedInstance().startRecording()
...
ClueController.sharedInstance().stopRecording()
- To disable ability to record bug reports just call
disable()
method fromClueController
.
Objective-C :
[[ClueController sharedInstance] disable];
Swift :
ClueController.sharedInstance().disable()
Clue supports iOS 9+ devices.
The current version of Clue (and the master
branch) is compatible with Xcode 8.
I love this part. Clue designed in a way, that you can tweak whatever you want and add more functionalities to report recording process (and I encourage you to do so). It means, that Clue will fit into your custom needs. For example, you want to track your custom logs during recording. Just create a separate module for that and plug it to the recording process (I’ll explain this in details below).
For now Clue is built with Objective-C (at least for the first iteration. I’m considering to rewrite some parts to Swift). So all internals were not designed to be Swift compatible. (If you can fix it — contributions are welcome!)
For more details please check out full documentation and code examples below.
There are two main concepts in Clue which you need to understand in order to build it by yourself:
- Modules
- Recordable modules
- Info modules
- Writers
Modules are responsible for actual data handling. Those classes observe some specific data during recording or collect this data just once during record launch. There are two module types: CLURecordableModule
and CLUInfoModule
.
CLURecordableModule
— is a protocol. It describes recordable module (like Video, View Structure, Network modules etc) which needs to track or inspect some specific information over time (like view structure for example) and record this information with specific timestamp using Writers
Note : Every recordable modules have to implement this protocol to be able to work normally inside the system
If your Recordable Module needs to observe new data instead of writing new data with every new frame you should use (subclass your new module from) CLUObserveModule
which provide common interface to write new data as soon as data become available.
CLUInfoModule
— is a protocol. It describes info modules (like Device Info module), static one-time modules which needs to write their data only once during whole recording also using Writers
Note : Every info modules have to implement this protocol to be able to work normally inside the system
Writers are responsible for writing some specific content (video, text etc) into the file.
CLUWritable
— is a protocol. It describes writers (like CLUDataWriter
or CLUVideoWriter
) which needs to actually write new data to specific file (could be text file, video file etc.)
Let’s assume you want to add module which will intercept logs and write them into json file with specific timestamp inside .clue report file.
First of you need to create new module class and subclass it from CLUObserveModule
(we’re assuming here that we need to observe new logs and they are not available right away)
@interface CLULogsModule : CLUObserveModule
@end
Next we need to implement some methods from CLURecordableModule
protocol:
@implementation CLULogsModule
- (instancetype)initWithWriter:(id <CLUWritable>)writer {
// If you will write just text data into json file you can use CLUDataWriter as an argument for this method. CLUDataWriter implements CLUWritable protocol
}
- (void)startRecording {
if (!self.isRecording) {
[super startRecording];
// Start logs interception
}
}
- (void)stopRecording {
if (self.isRecording) {
[super stopRecording];
// Stop logs interception
}
}
// This method will be called every time new frame with new timestamp is available.
// Since you subclass your module from CLUObserveModule you don't need to implement this method. CLUObserveModule takes care about it with data buffer.
// - (void)addNewFrameWithTimestamp:(CFTimeInterval)timestamp { }
@end
Next let’s assume we have some kind of log delegate method which will be called every time new log record is available. In this delegate method you should compose NSDictionary
(if you want to write json) with all properties of this log data entity you want to record into final .clue report.
- (void)newLog:(NSString *)logText isAvailableWithDate:(NSDate *)date {
// Let's build NSDictionary with data we need
// You always should include timestamp property for every new data entry
NSDictionary *logDictionary =
@{@"text" : logText,
@"date" : date,
TIMESTAMP_KEY : self.currentTimeStamp}; // TIMESTAMP_KEY - is a #define with default name for timetamp declared in CLUObserveModule. self.currentTimeStamp – is a property declared in CLUObserveModule which is updates every [CLUObserveModule addNewFrameWithTimestamp:] method call. So you can use it to indicate current timestamp
// Now we need to check validity of NSDictionary so we can convert it in to NSData as a json
if ([NSJSONSerialization isValidJSONObject:touchDictionary]) {
NSError *error;
// Convert NSDictionary to NSData
NSData *logData = [NSJSONSerialization dataWithJSONObject:logDictionary options:0 error:&error];
// Add log data to data buffer from CLUObserveModule with [CLUObserveModule addData:]
[self addData:logData];
}
}
To make it work you also need to plug your new module into the system of modules. The CLUReportComposer
is exactly for this purposes.
CLUReportComposer
is a class responsible for composing final Clue report from many modules. This class initialize all recordable and info modules and actually start recording. Also this class is callingaddNewFrameWithTimestamp:
method for every recordable module during recording andrecordInfoData
method fromCLURecordableModule
protocol for every info module only once at record launch.
So in ClueController
you need to add your new module in this method
- (NSMutableArray *)configureRecordableModules {
CLUVideoModule *videoModul = [self configureVideoModule];
CLUViewStructureModule *viewStructureModule = [self configureViewStructureModule];
CLUUserInteractionModule *userInteractionModule = [self configureUserInteractionModule];
CLUNetworkModule *networkModule = [self configureNetworkModule];
// Initialize your module just like other modules above
CLULogsModule *logModule = [self configureLogsModule]
NSMutableArray *modulesArray = [[NSMutableArray alloc] initWithObjects:videoModul, viewStructureModule, userInteractionModule, networkModule, /* HERE goes your module, */ nil];
return modulesArray;
}
Here is configuration method example:
- (void)configureLogsModule {
// Get directory URL for all recordable modules inside .clue file
NSURL *recordableModulesDirectory = [[CLUReportFileManager sharedManager] recordableModulesDirectoryURL];
// Specify URL for output json file
NSURL *outputURL = [recordableModulesDirectory URLByAppendingPathComponent:@"module_logs.json"];
// Initialize CLUDataWriter with specific output file URL
CLUDataWriter *dataWriter = [[CLUDataWriter alloc] initWithOutputURL:outputURL];
// Initialize CLULogsModule with specific Writer
CLULogsModule *logsModule = [[CLULogsModule alloc] initWithWriter:dataWriter];
return logsModule;
}
That’s it. Now you have logs inside your .clue report file as well as other useful data for fast bug fixing. You can literally build any module you want which can record any possible data inside your .clue bug report file.
Now let’s assume you want to add info module which will collect some initial data at the beginning of report recording for current users's location and write in into json file inside .clue report file.
First of you need to create new info module class and declare CLUInfoModule
protocol in the header file.
CLUInfoModule
protocol describe info modules (like Device Info module or Exception module), static one-time modules which needs to write their data only once during recording. Every info modules have to implement this protocol to be able to work normally inside the system
@interface CLULocationInfoModule : NSObject <CLUInfoModule>
@end
Next we need to implement some methods from CLUInfoModule
protocol:
@interface CLULocationInfoModule()
@property (nonatomic) CLUDataWriter *writer; // Just keep reference to Writer object from initialization
@end
@implementation CLULocationInfoModule
// If you will write just text data into json file you can use CLUDataWriter as an argument for this method. CLUDataWriter implements CLUWritable protocol so you can write any NSData object into output file
- (instancetype)initWithWriter:(id <CLUWritable>)writer {
// Basic initialization stuff
_writer = writer;
return self;
}
// This method will be called once at recording startup. Here you can add all your required data (in our case user's location) into report file
- (void)recordInfoData {
NSData *locationData = [self retrieveLocationData];
// Add location data to the output file via Writer object
[_writer startWriting];
[_writer addData:locationData];
[_writer finishWriting];
}
@end
To make it work you also need to plug your new info module into the system of modules (just like recordable module) using CLUReportComposer
.
CLUReportComposer
is a class responsible for composing final Clue report from many modules. This class initialize all recordable and info modules and actually start recording. Also this class is callingaddNewFrameWithTimestamp:
method for every recordable module during recording andrecordInfoData
method fromCLURecordableModule
protocol for every info module only once at record launch.
So in ClueController
you need to add your new info module in this method
- (NSMutableArray *)configureInfoModules {
CLUDeviceInfoModule *deviceModule = [self configureDeviceInfoModule];
// Initialize your info module just like other info modules above
CLULocationInfoModule *locationModule = [self configureLocationModule];
NSMutableArray *modulesArray = [[NSMutableArray alloc] initWithObjects:deviceModule, /* HERE goes your info module ,*/ nil];
return modulesArray;
}
Here is configuration method example:
- (void)configureLocationModule {
// Get directory URL for all info modules inside .clue file
NSURL *infoModulesDirectory = [[CLUReportFileManager sharedManager] infoModulesDirectoryURL];
// Specify URL for output json file
NSURL *outputURL = [infoModulesDirectory URLByAppendingPathComponent:@"info_location.json"];
// Initialize CLUDataWriter with specific output file URL
CLUDataWriter *dataWriter = [[CLUDataWriter alloc] initWithOutputURL:outputURL];
// Initialize CLULocationInfoModule with specific Writer
CLULocationInfoModule *locationModule = [[CLULogsModule alloc] initWithWriter:dataWriter];
return locationModule;
}
That’s it. Now you have location information inside your .clue report file.
Clue records views' structure. This means that Clue needs to parse all properties and subviews for every visible View (and invisible as well) on the screen to be able to represent whole views' structure at the current time.
UIView categories are used for this purposes. There are following categories for UIView related classes:
UIView (CLUViewRecordableAdditions)
UILabel (CLUViewRecordableAdditions)
UIImageView (CLUViewRecordableAdditions)
UITextField (CLUViewRecordableAdditions)
UIView (CLUViewRecordableAdditions) category has methods to parse basic view properties (this is general for every view object) and recursively parse subviews.
Other categories made to parse View specific properties (like text
property for UITextField
, for example).
If you want to parse some specific view properties and add them to final report you have to create separate category like this
@interface AHCustomView (CLUViewRecordableAdditions) <CLUViewRecordableProperties>
@end
Every new View category always have to implement method from
CLUViewRecordableProperties
protocol to be able to use root properties from base view class (UIView
, for example) so all properties would be in place for your custom view object as well.
Now we need to implement actual property parsing
// Method from CLUViewRecordableProperties protocol
- (NSMutableDictionary *)clue_viewPropertiesDictionary {
// First of you need to get property dictionary from view's superclass
NSMutableDictionary *rootDictionary = [super clue_viewPropertiesDictionary];
// Change class name, so it would be real instead of superclass' class name
[rootDictionary setObject:NSStringFromClass([self class]) forKey:@"class"];
// Next you want to add some view specific properties into root dictionary.
NSMutableDictionary *propertiesDictionary = [rootDictionary objectForKey:@"properties"];
// Let's add text property just for example
[propertiesDictionary setObject:self.text ?: @""
forKey:@"text"];
…
// Add new properties dictionary to root dictionary
[rootDictionary setObject:propertiesDictionary
forKey:@"properties"];
// Return root dictionary as a result
return rootDictionary;
}
That’s it, now even your custom view classes will show specific properties in final .clue report.
It’s basically just a package file with json data from network, view structure, user interactions modules and device info module and .mp4 video file from video module.
Here is tree representation of Report.clue
file:
Report.clue
├── Info
│ └── info_device.json
└── Modules
├── module_interaction.json
├── module_network.json
├── module_video.mp4
└── module_view.json
I also made macOS companion app (open source as well) which allow you to view .clue report files in a nice and simple way. It shows full timeline with all event so you can inspect dependencies between user actions and actual app’s responses pretty easily.
Obviously you can view .clue report files with whatever method you want since it’s just json files and mp4 video file combined inside single entity.
- Build basic modules for Network, View Structure, User Interactions and Video
- Send final report file via email
- Skip recordable property if this property is invalid
- Integrate Nonnull and Nullable annotations
- Migrate some parts of the framework to Swift
- Add more useful recordable/info modules
- Slack integration
- macOS version of the framework to use in macOS apps
- Suggestions are welcome!
Contributions are welcome! That's why Open Source is cool!
Please check out the Contributing Guide for more details.
Feel free to reach me on twitter @ahmed_sulajman or drop me a line on [email protected]
I Hope Clue framework will help you with your bug reports!
Clue is released under the MIT License. See the LICENSE file.