[orx-crash-handler] Add crash handler with Slack reporter

This commit is contained in:
Edwin Jakobs
2025-10-24 12:18:00 +02:00
parent 0d43c330cb
commit 4204ee5eed
9 changed files with 239 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# orx-crash-handler
Extension for reporting unhandled exceptions

View File

@@ -0,0 +1,11 @@
plugins {
id("org.openrndr.extra.convention.kotlin-jvm")
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(sharedLibs.kotlin.serialization.json)
implementation(openrndr.application.core)
implementation(openrndr.math)
implementation(libs.okhttp)
}

View File

@@ -0,0 +1,26 @@
import org.openrndr.application
import org.openrndr.extra.crashhandler.CrashHandler
import org.openrndr.extra.crashhandler.slack
fun main() {
application {
configure {
width = 1280
height = 720
}
program {
extend(CrashHandler()) {
name = "jump-scare"
vncHost = "localhost"
slack {
authToken = System.getenv("SLACK_AUTH_TOKEN")
channelId = System.getenv("SLACK_CHANNEL_ID")
}
}
extend {
error("something bad happened")
}
}
}
}

View File

@@ -0,0 +1,49 @@
package org.openrndr.extra.crashhandler
import io.github.oshai.kotlinlogging.KotlinLogging
import org.openrndr.Extension
import org.openrndr.Program
import java.io.File
private val logger = KotlinLogging.logger { }
class CrashHandler : Extension {
override var enabled: Boolean = true
var name: String? = null
var vncHost: String? = null
val reporters = mutableListOf<Reporter>()
override fun setup(program: Program) {
if (name == null)
name = program.name
Thread.setDefaultUncaughtExceptionHandler { t, e: Throwable ->
logger.error(e) { "Uncaught exception in thread $t" }
for (reporter in reporters) {
try {
reporter.reportCrash(e)
} catch (e: Exception) {
println("error while reporting")
logger.error(e) { "reporter threw an exception" }
}
}
val crashFile = File("${program.name}.crash")
val lastCrash = if (crashFile.exists()) crashFile.readText().toLongOrNull() ?: 0L else 0L
crashFile.writeText("${System.currentTimeMillis()}")
if (System.currentTimeMillis() - lastCrash < 60 * 1000) {
logger.info { "crashed less than 60 seconds ago, sleeping for 60 seconds" }
Thread.sleep(60 * 1000L)
}
System.exit(1)
}
}
}

View File

@@ -0,0 +1,5 @@
package org.openrndr.extra.crashhandler
abstract class Reporter(val handler: CrashHandler) {
abstract fun reportCrash(throwable: Throwable)
}

View File

@@ -0,0 +1,141 @@
package org.openrndr.extra.crashhandler
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@Serializable
private data class BlockResponse(
val type: String,
val text: TextElementResponse? = null,
val elements: List<BlockElementResponse> = listOf()
)
@Serializable
private data class TextElementResponse(
val text: String,
val type: String,
val emoji: Boolean = false
)
@Serializable
private data class BlockElementResponse(
val type: String,
val text: TextElementResponse,
val url: String? = null,
val style: String = "primary",
)
@Serializable
private data class ChatPostMessageRequest(
val channel: String,
val blocks: List<BlockResponse>? = null,
val text: String? = null,
val thread_ts: String? = null
)
@Serializable
private data class ChatPostMessageResponse(
val ok: Boolean,
val ts: String? = null,
val channel: String? = null
)
private val logger = KotlinLogging.logger { }
class SlackReporter(handler: CrashHandler) : Reporter(handler) {
var channelId: String = ""
var authToken: String = ""
private val monitorJson = Json {
ignoreUnknownKeys = true
}
private fun makeRequest(client: OkHttpClient, messageRequest: ChatPostMessageRequest): Response {
val body = monitorJson.encodeToString(messageRequest)
val requestBody = body.toRequestBody("application/json".toMediaType())
val replySlackRequest: Request = Request.Builder()
.url("https://slack.com/api/chat.postMessage")
.method("POST", requestBody)
.addHeader("Content-Type", "application/json")
.addHeader("Authorization", "Bearer $authToken")
.build()
val response = client.newCall(replySlackRequest).execute()
require(response.isSuccessful) {
"request failed: ${response.code} ${response.message}"
}
return response
}
private fun plainText(text: String): TextElementResponse {
return TextElementResponse(text, "plain_text", false)
}
private fun slackMessage(client: OkHttpClient, endpoint: String, error: Boolean = false, errorLog: String? = null) {
val messageRequest = if (error) {
ChatPostMessageRequest(channel = channelId!!, thread_ts = null,
blocks = listOf(
BlockResponse("section", plainText("There is a problem with $endpoint. Please check.")),
BlockResponse("actions", elements = listOfNotNull(
if (handler.vncHost != null) {
BlockElementResponse(
type = "button",
text = plainText("VNC into $endpoint"),
url = "vnc://${handler.vncHost}"
)
} else { null }
)
)
)
)
} else {
ChatPostMessageRequest(channel = channelId!!, thread_ts = null,
blocks = listOf(BlockResponse("section", plainText("$endpoint is back online!")))
)
}
val response = makeRequest(client, messageRequest)
if (error && response.isSuccessful) {
val cmr = monitorJson.decodeFromString<ChatPostMessageResponse>(response.body?.string() ?: "")
val logMessage = errorLog ?: "No log could be retrieved. Machine is likely unreachable"
val replyRequest = ChatPostMessageRequest(channel = channelId, thread_ts = cmr.ts,
blocks = listOf(
BlockResponse(
type = "section",
text = TextElementResponse("```${logMessage}```", "mrkdwn", false)
)
),
)
makeRequest(client, replyRequest)
}
}
override fun reportCrash(throwable: Throwable) {
logger.info { "reporting " }
val client = OkHttpClient().newBuilder().build()
slackMessage(client, handler.name ?: "no name", true, throwable.stackTraceToString())
}
}
fun CrashHandler.slack(config: SlackReporter.() -> Unit) {
reporters.add(SlackReporter(this).apply(config))
}