As an app developer, you probably send hundreds of app notifications to users nudging them to try a new game level, complete a purchase, read a new article, or so on. But have you ever wondered what impact those notifications are having on user behavior? Are they actually improving the metrics you care about?
Firebase Cloud Messaging enables you to deliver and receive messages and notifications on Android, iOS, and the web at no cost. Measuring the impact of these notifications is an important but somewhat difficult task to accomplish. Cloud Messaging has always used Google Analytics to count sent notifications, and now, there’s a new way to see other user events related to an open notification across sessions or a longer period of time. Using these events will not only provide more information about the notifications you’re sending to users, they will also enable you to better measure the impact of sending notifications to users.
With analytics labels in Cloud Messaging, you have the ability to pick and apply analytics labels to messages, which Google Analytics can use to track all events related to your notifications beyond just counting sent ones. What is an analytics label? The label is a text string that you can add to any message. For example, let’s say you wanted to see all of the opened notifications for users who signed up in January. You can now do that by attaching a “january” label to your notifications when sending.
You can attach a label for any notification sent via the Firebase Cloud Messaging API as well as for a messaging campaign in Firebase Console. When you set up a Cloud Messaging campaign in the Notifications composer, you can use the dropdown menu in step 4 for Conversion events to choose an analytics label.
This will attach an analytics label to all messages sent as part of this campaign.
After some time has passed, you can get insight into the impact of those messages. Cloud Messaging in Firebase Console has a Reports tab with a graph showing funnel analysis - the number of sends, received, impressions, and opened notifications for a given timeframe. Filtering using an analytics label shows the information for just that label.
Aside from being able to see the impact of labels in Reports, you can use them to perform other specific Google Analytics analyses. For example, perhaps you want to compare how your game play time during winter holidays performed against summertime; you can use comparisons to evaluate metrics like engagement time for those two different messaging campaigns. You can also use the cohort exploration feature to compare user activity, and in fact, you can use the labels as event parameters on the messages for any Google Analytics analyses!
Viewing User Engagement card with label comparisons applied.
Cohort analysis using labeled notification campaign segments.
So there we have it! Using analytics labels in Cloud Messaging, we are able to leverage the full power of Google Analytics to measure the impact of messages sent via the API and Firebase Console!
We’ve heard your feedback about the challenges you’ve experienced when uploading dSYMs to Crashlytics, especially for apps with bitcode enabled. We’re working to improve this process and wanted to share a way to automate it by using fastlane.
When building apps without bitcode, the Crashlytics upload-symbols tool that you include as a run script automatically uploads your app’s dSYM files every time you rebuild your app. When the app sends us crash reports, these files allow us to symbolicate the stack frames included in the reports so that you see source code rather than compiled code on the Crashlytics dashboard.
upload-symbols
However, for apps with bitcode enabled, the process of uploading dSYMs isn’t automatic. Instead, you need to download updated dSYMs from Apple and upload them manually to Crashlytics each time you submit a new version of the app to App Store Connect.
This manual process can be a pain especially since non-technical team members are often responsible for uploading bitcode dSYMs.With fastlane, you can use a single command to automatically download and upload your bitcode dSYMs.
Fastlane is an open-source tool that automates the build and release workflow for iOS and Android apps, including tasks like code signing and generating screenshots. See the official fastlane documentation for more information.
There are a couple of fastlane tools that will help you automate the process of uploading dSYMs to Crashlytics: actions, lanes, and Fastfiles.
actions
lanes
Fastfiles
An action is one of the building blocks of fastlane. Each action performs a single command, such as running tests or performing code validation.
action
One or more actions can be included in a lane. You can think of a lane as a workflow that is (usually) composed of related tasks. For this Crashlytics workflow, the lane will consist of three actions: downloading dSYMs from Apple, uploading dSYMs to Crashlytics, then cleaning up the files. Each lane also has a name and description.
lane
And finally, a Fastfile manages the lane created in this workflow, as well as any others you use for your project.
Fastfile
You can set up fastlane in multiple ways, including with Bundler and Homebrew. For instructions, see fastlane's Getting Started for iOS guide. During the fastlane init step, make sure to choose the manual setup option.
fastlane init
Once fastlane is set up, the first step toward automating symbol uploads is configuring a lane. We’ll do this by editing the Fastfile that was created in the fastlane directory during the setup step. Let's start by modifying the default lane, which currently looks like this:
fastlane
desc "Description of what the lane does" lane :custom_lane do # add actions here: https://docs.fastlane.tools/actions end
For the desc field, we’ll include a brief summary of the lane’s purpose:
desc
desc "Downloads dSYM files from Apple for a given app version and uploads them to Crashlytics"
And then give the lane a name:
lane :upload_symbols do
Next, we'll add actions to our lane. There are three actions that we'll use: download_dsyms, upload_symbols_to_crashlytics, and clean_build_artifacts. (For fastlane’s documentation on any of these commands, run fastlane action command_name in your terminal.)
download_dsyms
upload_symbols_to_crashlytics
clean_build_artifacts
fastlane action command_name
download_dsyms will do the work of downloading the new dSYM files from Apple. We’ll just need to provide the action with either 1) the version number and build for the app version that you want to download dSYMs for or 2) the live and latest version constants to download the live or latest version’s dSYMs, respectively. Optionally, you can also specify your app’s bundle ID and your App Store Connect username to avoid having to enter them manually when you run the action. All in all, this action will look similar to one of the following:
live
latest
download_dsyms(version: "1.0.0", build: "1.0.0", app_identifier: "bundleID")
download_dsyms(version: "live")
download_dsyms(username: "username", version: "latest", app_identifier: "bundleID")
In this guide, we'll use the last option (i.e, latest version, specifying both bundle ID and App Store Connect username).
upload_symbols_to_crashlytics will take the files from download_dsyms and upload them to Crashlytics. This command takes as a parameter the GoogleService-Info.plist file for the app, like so: upload_symbols_to_crashlytics(gsp_path: "path/to/GoogleService-Info.plist")
GoogleService-Info.plist
upload_symbols_to_crashlytics(gsp_path: "path/to/GoogleService-Info.plist")
Note: If you’re using Swift Package Manager (instead of CocoaPods), you’ll need to download the Crashlytics upload-symbols tool, place it in your project directory, make it executable (chmod +x upload-symbols), then set the binary_path variable in the upload_symbols_to_crashlytics action: upload_symbols_to_crashlytics(gsp_path: "path/to/GoogleService-Info.plist", binary_path: "upload-symbols").
chmod +x upload-symbols
binary_path
upload_symbols_to_crashlytics(gsp_path: "path/to/GoogleService-Info.plist", binary_path: "upload-symbols")
Lastly, we’ll use clean_build_artifacts to delete the dSYM files once they’ve been uploaded.
Our lane now looks something like this:
desc "Downloads dSYM files from Apple for a given app build and version and uploads them to Crashlytics" lane :upload_symbols do download_dsyms(username: "username", version: "latest", app_identifier: "bundleID") upload_symbols_to_crashlytics(gsp_path: "path/to/GoogleService-Info.plist") clean_build_artifacts end
(If you’re using Swift Package Manager, the only difference will be the binary_path variable in the upload_symbols_to_crashlytics action.)
And here's our Fastfile with just this one lane:
default_platform(:ios) platform :ios do desc "Downloads dSYM files from Apple for a given app version and uploads them to Crashlytics" lane :upload_symbols do download_dsyms(username: "username", version: "latest", app_identifier: "bundleID") upload_symbols_to_crashlytics(gsp_path: "path/to/GoogleService-Info.plist") clean_build_artifacts end end
To run this lane, run fastlane upload_symbols from the terminal inside your project folder (replace upload_symbols if you chose a different name for the lane). You’ll be asked to log into your App Store Connect account (and select your App Store Connect team, if you have more than one associated with your account). If needed, you can customize the actions in the file to include more information about your account to avoid having to log in manually.
fastlane upload_symbols
upload_symbols
Get started with fastlane and start automating your dSYM uploads!
We’re always looking for ways to make Crashlytics work better for developers. We want to thank everyone who has provided us with feedback on GitHub, Firebase Support, and other channels about the dSYM upload process, and we encourage you to continue to reach out with questions and suggestions!
The Cloud Firestore C++ SDK uses the firebase::firestore::FieldValue class to represent document fields. A FieldValue is a union type that may contain a primitive (like a boolean or a double), a container (e.g. an array), some simple structures (such as a Timestamp) or some Firestore-specific sentinel values (e.g. ServerTimestamp) and is used to write data to and read data from a Firestore database.
firebase::firestore::FieldValue
FieldValue
Timestamp
ServerTimestamp
Other Firebase C++ SDKs use firebase::Variant for similar purposes. A Variant is also a union type that may contain primitives or containers of nested Variants; it is used, for example, to write data to and read data from the Realtime Database, to represent values read from Remote Config, and to represent the results of calling Cloud Functions using the Firebase SDK. If your application is migrating from Realtime Database to Firestore (or uses both side-by-side), or, for example, uses Firestore to store the results of Cloud Functions, you might need to convert between Variants and FieldValues.
firebase::Variant
Variant
In many ways, FieldValue and Variant are similar. However, it is important to understand that for all their similarities, neither is a subset of the other; rather, they can be seen as overlapping sets, each reflecting its domain. These differences make it impossible to write a general-purpose converter between them that would cover each and every case -- instead, handling each instance where one type doesn’t readily map to the other would by necessity have to be application-specific.
With that in mind, let’s go through some sample code that provides one approach to conversion; if your application needs to convert between FieldValue and Variant, it should be possible to adapt this code to your needs. Full sample code is available here.
The one area where FieldValues and Variants correspond to each other exactly are the primitive values. Both FieldValue and Variant support the exact same set of primitives, and conversion between them is straightforward:
FieldValue ConvertVariantToFieldValue(const Variant& from) { switch (from.type()) { case Variant::Type::kTypeNull: return FieldValue::Null(); case Variant::Type::kTypeBool: return FieldValue::Boolean(from.bool_value()); case Variant::Type::kTypeInt64: return FieldValue::Integer(from.int64_value()); case Variant::Type::kTypeDouble: return FieldValue::Double(from.double_value()); } } Variant Convert(const FieldValue& from) { switch (from.type()) { case FieldValue::Type::kNull: return Variant::Null(); case FieldValue::Type::kBoolean: return Variant(from.boolean_value()); case FieldValue::Type::kInteger: return Variant(from.integer_value()); case FieldValue::Type::kDouble: return Variant(from.double_value()); } }
Variant distinguishes between mutable and static strings and blobs: a mutable string (or blob) is owned by the Variant and can be modified through its interface, whereas a static string (or blob) is not owned by the Variant (so the application needs to ensure it stays valid as long as the Variant’s lifetime hasn’t ended; typically, this is only used for static strings) and cannot be modified.
Firestore does not have this distinction -- the strings and blobs held by FieldValue are always immutable (like static strings or blobs in Variant) but owned by the FieldValue (like mutable strings or blobs in Variant). Because ownership is the more important concern here, Firestore strings and blobs should be converted to mutable strings and blobs in Variant:
// `FieldValue` -> `Variant` case FieldValue::Type::kString: return Variant(from.string_value()); case FieldValue::Type::kBlob: return Variant::FromMutableBlob(from.blob_value(), from.blob_size()); // `Variant` -> `FieldValue` case Variant::Type::kTypeStaticString: case Variant::Type::kTypeMutableString: return FieldValue::String(from.string_value()); case Variant::Type::kTypeStaticBlob: case Variant::Type::kTypeMutableBlob: return FieldValue::Blob(from.blob_data(), from.blob_size());
Both FieldValues and Variants support arrays (called “vectors” by Variant) and maps, so for the most part, converting between them is straightforward:
// `FieldValue` -> `Variant` case FieldValue::Type::kArray: return ConvertArray(from.array_value()); case FieldValue::Type::kMap: return ConvertMap(from.map_value()); } // `Variant` -> `FieldValue` case Variant::Type::kTypeVector: return ConvertArray(from.vector()); case Variant::Type::kTypeMap: return ConvertMap(from.map()); } // ... FieldValue ConvertArray(const std::vector<Variant>& from) { std::vector<FieldValue> result; result.reserve(from.size()); for (const auto& v : from) { result.push_back(Convert(v)); } return FieldValue::Array(std::move(result)); } FieldValue ConvertMap(const std::map<Variant, Variant>& from) { MapFieldValue result; for (const auto& kv : from) { // Note: Firestore only supports string keys. If it's possible // for the map to contain non-string keys, you would have to // convert them to a string representation or skip them. assert(kv.first.is_string()); result[kv.first.string_value()] = Convert(kv.second); } return FieldValue::Map(std::move(result)); } Variant ConvertArray(const std::vector<FieldValue>& from) { std::vector<Variant> result; result.reserve(from.size()); for (const auto& v : from) { result.push_back(Convert(v)); } return Variant(result); } Variant ConvertMap(const MapFieldValue& from) { std::map<Variant, Variant> result; for (const auto& kv : from) { result[Variant(kv.first)] = Convert(kv.second); } return Variant(result); }
Firestore does not support nested arrays (that is, one array being a direct member of another array). FieldValue itself would not reject a nested array, though -- it will only be rejected by Firestore’s input validation when passed to a Firestore instance (like in a call to DocumentReference::Set).
DocumentReference::Set
The approach to handling this case would have to be application-specific. For example, you might simply omit nested arrays, perhaps logging a warning upon encountering them; on the other extreme, you may want to terminate the application:
FieldValue ConvertArray(const std::vector<Variant>& from) { std::vector<FieldValue> result; result.reserve(from.size()); for (const auto& v : from) { if (v.type() == Variant::Type::kTypeVector) { // Potential approach 1: log and forget LogWarning("Skipping nested array"); continue; // Potential approach 2: terminate assert(false && "Encountered a nested array"); std::terminate(); } result.push_back(Convert(v)); } return FieldValue::Array(std::move(result)); }
Yet another approach might be to leave the nested arrays in place and rely on Firestore input validation to reject them (this approach is mostly applicable if you don’t expect your data to contain any nested arrays).
One possible workaround if you need to pass a nested array to Firestore might be to represent arrays as maps:
case Variant::Type::kTypeVector: { MapFieldValue result; const std::vector<Variant>& array = from.vector(); for (int i = 0; i != array.size(); ++i) { result[std::to_string(i)] = Convert(array[i]); } return FieldValue::Map(std::move(result)); }
Another approach, which has the nice property of being generalizable to other cases, is to automatically translate the structure of “array-array” into “array-map-array” when converting to FieldValue.
If you decide to use this approach, you will need to ensure that the translated structure roundtrips properly (assuming your application needs bidirectional conversion). That is, an “array-map-array” structure within a FieldValue converts back to an “array-array” structure in Variant. To achieve this, the artificial map would have to be somehow marked to indicate that it does not represent an actual value in the database.
Once again, the implementation for this would be application-specific. You could add a boolean field called “special” with its value set to true and establish a convention that a map that contains a “special” field never represents user input. If this is not true for your application, you might use a more distinct name than “special” or come up with a different convention altogether.
true
These next two examples use “special” as a marker, but please keep in mind that it’s just one possible approach:
// `Variant` -> `FieldValue` FieldValue Convert(const Variant& from, bool within_array = false) { switch (from.type()) { // ... case Variant::Type::kTypeVector: if (!within_array) { return ConvertArray(from.vector()); } else { // Firestore doesn't support nested arrays. As a workaround, create an // intermediate map to contain the nested array. return FieldValue::Map({ {"special", FieldValue::Boolean(true)}, {"type", FieldValue::String("nested_array")}, {"value", ConvertArray(from.vector())}, }); } } } FieldValue ConvertArray(const std::vector<Variant>& from) { std::vector<FieldValue> result; result.reserve(from.size()); for (const auto& v : from) { result.push_back(Convert(v, /*within_array=*/true)); } return FieldValue::Array(std::move(result)); } // `FieldValue` -> `Variant` Variant Convert(const FieldValue& from) { switch (from.type()) { // ... case FieldValue::Type::kArray: return ConvertArray(from.array_value()); case FieldValue::Type::kMap: { const auto& m = from.map_value(); // Firestore doesn't support nested arrays, so nested arrays are instead // encoded as an "array-map-array" structure. Make sure nested arrays // round-trip. // Note: `TryGet*` functions are helpers to simplify getting values // out of maps. See their definitions in the full sample code. bool is_special = TryGetBoolean(m, "special"); if (is_special) { return ConvertSpecialValue(m); } else { return ConvertMap(from.map_value()); } } } Variant ConvertSpecialValue(const MapFieldValue& from) { // Note: in production code, you would have to handle // the case where the value is not in the map. // Note: `TryGet*` functions are helpers to simplify getting values // out of maps. See their definitions in the full sample code. std::string type = TryGetString(from, "type"); if (type == "nested_array") { // Unnest the array. return ConvertArray(TryGetArray(from, "value")); } // ... }
Finally, there are several kinds of entities supported by FieldValue that have no direct equivalent in Variant:
GeoPoint
DocumentReference
Similarly to nested arrays, your application could omit these values, issue errors upon encountering them, or else convert them into some representation supported by Variant. The exact representation would depend on the needs of your application and on whether the conversion is bidirectional or not (that is, whether it should be possible to convert the representation back into the original Firestore type).
An approach that is general (if somewhat heavyweight) and allows bidirectional conversion is to convert such structs into “special” maps. It could look like this:
// `FieldValue` -> `Variant` case FieldValue::Type::kTimestamp: { Timestamp ts = from.timestamp_value(); MapFieldValue as_map = { {"special", FieldValue::Boolean(true)}, {"type", FieldValue::String("timestamp")}, {"seconds", FieldValue::Integer(ts.seconds())}, {"nanoseconds", FieldValue::Integer(ts.nanoseconds())}}; return ConvertMap(as_map); } case FieldValue::Type::kGeoPoint: { GeoPoint gp = from.geo_point_value(); MapFieldValue as_map = { {"special", FieldValue::Boolean(true)}, {"type", FieldValue::String("geo_point")}, {"latitude", FieldValue::Double(gp.latitude())}, {"longitude", FieldValue::Double(gp.longitude())}}; return ConvertMap(as_map); } case FieldValue::Type::kReference: { DocumentReference ref = from.reference_value(); std::string path = ref.path(); MapFieldValue as_map = { {"special", FieldValue::Boolean(true)}, {"type", FieldValue::String("document_reference")}, {"document_path", FieldValue::String(path)}}; return ConvertMap(as_map); } FieldValue ConvertSpecialValue(const std::map<Variant, Variant>& from) { // Special values are Firestore entities encoded as maps because they are not // directly supported by `Variant`. The assumption is that the map contains // a boolean field "special" set to true and a string field "type" indicating // which kind of an entity it contains. std::string type = TryGetString(from, "type"); if (type == "timestamp") { Timestamp result(TryGetInteger(from, "seconds"), TryGetInteger(from, "nanoseconds")); return FieldValue::Timestamp(result); } else if (type == "geo_point") { GeoPoint result(TryGetDouble(from, "latitude"), TryGetDouble(from, "longitude")); return FieldValue::GeoPoint(result); } // ...
The only complication here is that to convert a “special” map back to a DocumentReference, you would need a pointer to a Firestore instance so that you may call Firestore::Document. If your application always uses the default Firestore instance, you might simply call Firestore::GetInstance. Otherwise, you can pass Firestore* as an argument to Convert or make Convert a member function of a class, say, Converter, that acquires a pointer to a Firestore instance in its constructor.
Firestore::Document
Firestore::GetInstance
Firestore*
Convert
Converter
} else if (type == "document_reference") { DocumentReference result = firestore->Document(TryGetString(from, "document_path")); return FieldValue::Reference(result); }
One more thing to note is that Realtime Database represents timestamps as the number of milliseconds since the epoch in UTC. If you intend to use the resulting Variant in the Realtime Database, a more natural representation for a Timestamp might thus be an integer field. However, you would have to provide some way to distinguish between numbers and timestamps in the Realtime Database -- a possible solution is to simply add a _timestamp suffix to the field name, but of course other alternatives are possible. In that case, the conversion from FieldValue to Variant might look like:
_timestamp
case FieldValue::Type::kTimestamp: { Timestamp ts = from.timestamp_value(); int64_t millis = ts.seconds() * 1000 + ts.nanoseconds() / (1000 * 1000); return Variant(millis); }
If bidirectional conversion is required, you would also have to somehow distinguish between numbers and timestamps when converting back to a FieldValue. If you're using the solution of adding _timestamp suffix to the field name, you would have to pass the field name to the converter. Another approach might be to use heuristics and presume that a very large number that readily converts to a reasonably recent date must be a timestamp.
Finally, there are some unique values in Firestore that represent a transformation to be applied to an existing value or a placeholder for a value to be supplied by the backend:
Firestore
Delete
ArrayUnion
ArrayRemove
IncrementInteger
IncrementDouble
Some of these values are only meaningful in Firestore, so most likely it wouldn’t make sense to try to convert them in your application. Otherwise, Delete and ServerTimestamp, being stateless, can be straightforwardly converted to maps using the approach outlined above. If you’re using Variant with the Realtime Database, you might want to represent a ServerTimestamp in the Realtime Database-specific format ( a map that contains a single element: {".sv" : "timestamp"}):
{".sv" : "timestamp"}
case FieldValue::Type::kServerTimestamp: return ConvertMap({{".sv", FieldValue::String("timestamp")}});
Similarly, you may represent Delete as a null in the Realtime Database:
case FieldValue::Type::kDelete: return Variant::Null();
However, other than Delete and ServerTimestamp, the rest of the sentinel values are stateful and there is no way to get their underlying value from a FieldValue, so lossless conversion is not possible. Likely the best thing to do is just to ensure these values are never passed to the converter and assert if they do.
With so much time spent on game development, the last thing any game developer wants to see is a low rating as a result of a buggy game. From our very first Crashlytics NDK release back in 2015, our commitment to game developers remains strong. And recently we released a number of NDK and Unity features that not only increase the stability of games, but also enable developers using Unreal, Cocos2d, Unity or any other native game engine to get to resolutions quickly and with more confidence. Read more about these improvements below.
To provide you with a more accurate view into the stability of your gaming apps, we’ve made significant changes to Crashlytics' native crash capture mechanism by adopting Google's open-source Crashpad library. As a result, you can now get access to several additional classes of errors on newer Android versions as well as get more reliable crash reporting on existing Android versions of your app. Additionally, with Crashpad you no longer have to deal with the inherent complexity of native crash capture and instead you can focus your time on growing your game.
The increased reliability of Crashpad lies in its pursuit of minimizing the amount of work done within the signal handler, which ultimately results in a more reliable crash capturing mechanism. One of Crashpad’s core design goals is to reduce the number of system calls inside the handler to just one. Upon receiving a signal, Crashpad captures the memory space of the crashed application by launching a brand new, healthy process, removing some of the problems of other crash capture approaches such as capturing SIGABRT on Android 10+ devices.
More accurate stack traces, especially within the application frames, lead to faster issue resolutions - there is no doubt about it! That is why we’ve switched our symbol file format to one that is more robust - the Breakpad symbol file. The additional debug information within the Breakpad symbol file, in many circumstances, results in a more accurate stack trace than what you’d see in the logcat.
The key to more accurate stack traces lies within the Call Frame Information that is stored within the debug section of the binaries. This information is what differentiates the Breakpad symbol file from our previous symbol file. Call frame information assists our backend stack unwinding process, minimizing the use of heuristics within application frames that may lead to incorrect stack traces. With this information, our backend more precisely determines how the frames within your application should be unwound and which symbols - inlined or not - correspond to each frame!
Take a look at the Google Games Developer Summit session that explains this in more detail. If you’re already using the Crashlytics NDK SDK, switching to the Breakpad symbol file is a breeze, just add symbolGenerator { breakpad() } to your build.gradle. Check out our docs for more info.
symbolGenerator { breakpad() }
The left image shows a stack that is unwound using our previous symbol format, and the right image shows the same stack unwound using the Breakpad symbol file.
The top image shows a frame symbolicated using our previous symbol format, and the bottom image shows the same frame symbolicated using the Breakpad symbol file.
We heard your feedback around symbol uploading for main applications and stand-alone libraries. And with the latest Crashlytics Gradle plugin, specifying the stripped library directory is no longer necessary so you can get quickly set up with minimal error. We’ve also allowed the unstripped path to point to disparate directories, allowing symbol upload for binaries that are compiled outside of the main application.
We’ve improved grouping for Unity crashes to help you more quickly debug and identify the exact cause of a crash. Our analysis backend now has a more robust set of Unity heuristics, resulting in much better issue fidelity and a more intuitive stack trace visual treatment. No longer will issues highlight a system frame when an application frame is the source of the problem.
Revised Unity Grouping in Crashlytics Console
We’ve enabled automatic capture of various hardware attributes and game-specific values to help solve unique issues related to the model of GPU and screen resolution for example, so you no longer have to capture these yourself and use up valuable key-value pairs.
Unity metadata in Crashlytics console
We hope these improvements help make identifying crashes much easier, and we will continue to improve the games experience by focusing on improving Unity IL2CPP support - support that includes capturing Unity Engine and native extension crashes. Let Crashlytics handle crashes for all of the components that your game depends on, because sometimes, the crash is not your game's fault. There is much more on the horizon, stay tuned!
In the meantime to get started, head to our NDK and Unity onboarding pages. We're excited to help you through your game development journey and can’t wait to hear what you think!