diff --git a/nubrick/src/main/kotlin/io/nubrick/nubrick/data/container.kt b/nubrick/src/main/kotlin/io/nubrick/nubrick/data/container.kt index b0b24b9..d1f0302 100644 --- a/nubrick/src/main/kotlin/io/nubrick/nubrick/data/container.kt +++ b/nubrick/src/main/kotlin/io/nubrick/nubrick/data/container.kt @@ -53,6 +53,7 @@ internal interface Container { fun storeNativeCrash(throwable: Throwable) fun sendFlutterCrash(crashEvent: TrackCrashEvent) + fun recordBreadcrumb(breadcrumb: Breadcrumb) fun handleNubrickEvent(it: NubrickEvent) } @@ -296,6 +297,10 @@ internal class ContainerImpl( this.trackRepository.sendFlutterCrash(crashEvent) } + override fun recordBreadcrumb(breadcrumb: Breadcrumb) { + this.trackRepository.recordBreadcrumb(breadcrumb) + } + override fun handleNubrickEvent(it: NubrickEvent) { this.config.onDispatch?.let { handle -> handle(it) diff --git a/nubrick/src/main/kotlin/io/nubrick/nubrick/data/track.kt b/nubrick/src/main/kotlin/io/nubrick/nubrick/data/track.kt index 416a628..c7a5c6c 100644 --- a/nubrick/src/main/kotlin/io/nubrick/nubrick/data/track.kt +++ b/nubrick/src/main/kotlin/io/nubrick/nubrick/data/track.kt @@ -41,10 +41,66 @@ data class ExceptionRecord( val callStacks: List? ) +/** + * The category of a breadcrumb. + * Based on Sentry's breadcrumb categories. + */ +@Serializable +enum class BreadcrumbCategory { + /** Screen navigation events */ + navigation, + /** User interaction events (taps, clicks, etc.) */ + ui, + /** HTTP request events */ + http, + /** Console log events */ + console, + /** Custom events */ + custom; + + companion object { + fun fromString(value: String): BreadcrumbCategory { + return entries.find { it.name == value } ?: custom + } + } +} + +/** + * The severity level of a breadcrumb. + * Based on Sentry's breadcrumb levels. + */ +@Serializable +enum class BreadcrumbLevel { + debug, + info, + warning, + error, + fatal; + + companion object { + fun fromString(value: String): BreadcrumbLevel { + return entries.find { it.name == value } ?: info + } + } +} + +/** + * Breadcrumb for crash reporting context + */ +@Serializable +data class Breadcrumb( + val message: String, + val category: BreadcrumbCategory = BreadcrumbCategory.custom, + val level: BreadcrumbLevel = BreadcrumbLevel.info, + val data: Map? = null, + val timestamp: Long +) + data class TrackCrashEvent( val exceptions: List, val platform: String? = null, val flutterSdkVersion: String? = null, + val breadcrumbs: List? = null, ) { internal fun encode(): JsonObject { val map = mutableMapOf( @@ -57,6 +113,9 @@ data class TrackCrashEvent( if (flutterSdkVersion != null) { map["flutterSdkVersion"] = JsonPrimitive(flutterSdkVersion) } + if (breadcrumbs != null) { + map["breadcrumbs"] = Json.encodeToJsonElement(breadcrumbs) + } return JsonObject(map) } } @@ -149,16 +208,21 @@ internal interface TrackRepository { fun storeNativeCrash(throwable: Throwable) fun sendFlutterCrash(crashEvent: TrackCrashEvent) + fun recordBreadcrumb(breadcrumb: Breadcrumb) + fun getBreadcrumbs(): List } internal class TrackRepositoryImpl: TrackRepository { private val queueLock: ReentrantLock = ReentrantLock() + private val breadcrumbLock: ReentrantLock = ReentrantLock() private val config: Config private val user: NubrickUser private var timer: Timer? = null private val maxBatchSize: Int = 50 private val maxQueueSize: Int = 300 + private val maxBreadcrumbSize: Int = 50 private var buffer: MutableList = mutableListOf() + private var breadcrumbBuffer: MutableList = mutableListOf() internal constructor(config: Config, user: NubrickUser) { this.config = config @@ -302,6 +366,27 @@ internal class TrackRepositoryImpl: TrackRepository { } override fun sendFlutterCrash(crashEvent: TrackCrashEvent) { - sendCrashToBackend(crashEvent) + // Get current breadcrumbs and include them in the crash event + val breadcrumbs = getBreadcrumbs() + val eventWithBreadcrumbs = crashEvent.copy( + breadcrumbs = crashEvent.breadcrumbs ?: breadcrumbs.ifEmpty { null } + ) + sendCrashToBackend(eventWithBreadcrumbs) + } + + override fun recordBreadcrumb(breadcrumb: Breadcrumb) { + this.breadcrumbLock.withLock { + this.breadcrumbBuffer.add(breadcrumb) + if (this.breadcrumbBuffer.size > this.maxBreadcrumbSize) { + val overflow = this.breadcrumbBuffer.size - this.maxBreadcrumbSize + repeat(overflow) { this.breadcrumbBuffer.removeAt(0) } + } + } + } + + override fun getBreadcrumbs(): List { + return this.breadcrumbLock.withLock { + this.breadcrumbBuffer.toList() + } } } diff --git a/nubrick/src/main/kotlin/io/nubrick/nubrick/sdk.kt b/nubrick/src/main/kotlin/io/nubrick/nubrick/sdk.kt index f86e9eb..555aad3 100644 --- a/nubrick/src/main/kotlin/io/nubrick/nubrick/sdk.kt +++ b/nubrick/src/main/kotlin/io/nubrick/nubrick/sdk.kt @@ -23,6 +23,9 @@ import io.nubrick.nubrick.data.CacheStore import io.nubrick.nubrick.data.Container import io.nubrick.nubrick.data.ContainerImpl import io.nubrick.nubrick.data.FormRepositoryImpl +import io.nubrick.nubrick.data.Breadcrumb +import io.nubrick.nubrick.data.BreadcrumbCategory +import io.nubrick.nubrick.data.BreadcrumbLevel import io.nubrick.nubrick.data.TrackCrashEvent import io.nubrick.nubrick.data.database.NubrickDbHelper import io.nubrick.nubrick.data.user.NubrickUser @@ -203,6 +206,60 @@ class NubrickExperiment { this.container.sendFlutterCrash(crashEvent) } + /** + * Records a breadcrumb for crash reporting context + * + * @param breadcrumb The breadcrumb to record + */ + @FlutterBridgeApi + fun recordBreadcrumb(breadcrumb: Breadcrumb) { + this.container.recordBreadcrumb(breadcrumb) + } + + /** + * Records a breadcrumb from Flutter Bridge data + * + * @param data Map containing breadcrumb data from Flutter's method channel. + * + * Expected structure from Flutter (see `lib/breadcrumb.dart`): + * ``` + * { + * "message": String, // Required: breadcrumb message + * "category": String, // Optional: "navigation", "ui", "http", "console", "custom" + * "level": String, // Optional: "debug", "info", "warning", "error", "fatal" + * "data": Map?, // Optional: additional key-value data + * "timestamp": Long // Required: milliseconds since epoch + * } + * ``` + */ + @FlutterBridgeApi + fun recordBreadcrumb(data: Map) { + // Flutter method channel passes String and Long from Dart + val message = data["message"] as? String ?: return + val timestamp = (data["timestamp"] as? Number)?.toLong() ?: return + // category and level are optional strings with defaults + val categoryString = data["category"] as? String ?: "custom" + val levelString = data["level"] as? String ?: "info" + val category = BreadcrumbCategory.fromString(categoryString) + val level = BreadcrumbLevel.fromString(levelString) + + // data is an optional map; we only keep String values + @Suppress("UNCHECKED_CAST") + val rawData = data["data"] as? Map + val stringData = rawData?.mapNotNull { (key, value) -> + (value as? String)?.let { key to it } + }?.toMap() + + val breadcrumb = Breadcrumb( + message = message, + category = category, + level = level, + data = stringData, + timestamp = timestamp + ) + this.container.recordBreadcrumb(breadcrumb) + } + @Composable fun Overlay() { Trigger(trigger = this.trigger)