Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions nubrick/src/main/kotlin/io/nubrick/nubrick/data/container.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ internal interface Container {

fun storeNativeCrash(throwable: Throwable)
fun sendFlutterCrash(crashEvent: TrackCrashEvent)
fun recordBreadcrumb(breadcrumb: Breadcrumb)
fun handleNubrickEvent(it: NubrickEvent)
}

Expand Down Expand Up @@ -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)
Expand Down
87 changes: 86 additions & 1 deletion nubrick/src/main/kotlin/io/nubrick/nubrick/data/track.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,66 @@ data class ExceptionRecord(
val callStacks: List<StackFrame>?
)

/**
* 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<String, String>? = null,
val timestamp: Long
)

data class TrackCrashEvent(
val exceptions: List<ExceptionRecord>,
val platform: String? = null,
val flutterSdkVersion: String? = null,
val breadcrumbs: List<Breadcrumb>? = null,
) {
internal fun encode(): JsonObject {
val map = mutableMapOf(
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -149,16 +208,21 @@ internal interface TrackRepository {

fun storeNativeCrash(throwable: Throwable)
fun sendFlutterCrash(crashEvent: TrackCrashEvent)
fun recordBreadcrumb(breadcrumb: Breadcrumb)
fun getBreadcrumbs(): List<Breadcrumb>
}

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<TrackEvent> = mutableListOf()
private var breadcrumbBuffer: MutableList<Breadcrumb> = mutableListOf()

internal constructor(config: Config, user: NubrickUser) {
this.config = config
Expand Down Expand Up @@ -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<Breadcrumb> {
return this.breadcrumbLock.withLock {
this.breadcrumbBuffer.toList()
}
}
}
57 changes: 57 additions & 0 deletions nubrick/src/main/kotlin/io/nubrick/nubrick/sdk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Any?>?, // Optional: additional key-value data
* "timestamp": Long // Required: milliseconds since epoch
* }
* ```
*/
@FlutterBridgeApi
fun recordBreadcrumb(data: Map<String, Any?>) {
// 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<String, Any?>
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)
Expand Down
Loading