From 4204ee5eedd80f6fbcada21885877d923d35d192 Mon Sep 17 00:00:00 2001 From: Edwin Jakobs Date: Fri, 24 Oct 2025 12:18:00 +0200 Subject: [PATCH] [orx-crash-handler] Add crash handler with Slack reporter --- .../extra/convention/kotlin-jvm.gradle.kts | 1 + gradle/libs.versions.toml | 2 + orx-jvm/orx-crash-handler/README.md | 3 + orx-jvm/orx-crash-handler/build.gradle.kts | 11 ++ .../src/demo/kotlin/DemoCrashHandler01.kt | 26 ++++ .../src/main/kotlin/CrashHandler.kt | 49 ++++++ .../src/main/kotlin/Reporter.kt | 5 + .../src/main/kotlin/SlackReporter.kt | 141 ++++++++++++++++++ settings.gradle.kts | 1 + 9 files changed, 239 insertions(+) create mode 100644 orx-jvm/orx-crash-handler/README.md create mode 100644 orx-jvm/orx-crash-handler/build.gradle.kts create mode 100644 orx-jvm/orx-crash-handler/src/demo/kotlin/DemoCrashHandler01.kt create mode 100644 orx-jvm/orx-crash-handler/src/main/kotlin/CrashHandler.kt create mode 100644 orx-jvm/orx-crash-handler/src/main/kotlin/Reporter.kt create mode 100644 orx-jvm/orx-crash-handler/src/main/kotlin/SlackReporter.kt diff --git a/build-logic/orx-convention/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts b/build-logic/orx-convention/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts index 56f40f13..7fab222f 100644 --- a/build-logic/orx-convention/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts +++ b/build-logic/orx-convention/src/main/kotlin/org/openrndr/extra/convention/kotlin-jvm.gradle.kts @@ -45,6 +45,7 @@ val demo: SourceSet by project.sourceSets.creating { "orx-runway", "orx-syphon", "orx-video-profiles", + "orx-crash-handler" ) if (project.name !in skipDemos) { collectScreenshots(project, this@creating) { } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 350360a6..5d2c2727 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,8 +24,10 @@ jsoup = "1.21.2" mockk = "1.14.2" processing = "4.4.10" nmcp = "1.1.0" +okhttp = "5.2.1" [libraries] +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } kotlin-scriptingJvm = { group = "org.jetbrains.kotlin", name = "kotlin-scripting-jvm", version.ref = "kotlin" } diff --git a/orx-jvm/orx-crash-handler/README.md b/orx-jvm/orx-crash-handler/README.md new file mode 100644 index 00000000..6a356e3f --- /dev/null +++ b/orx-jvm/orx-crash-handler/README.md @@ -0,0 +1,3 @@ +# orx-crash-handler + +Extension for reporting unhandled exceptions \ No newline at end of file diff --git a/orx-jvm/orx-crash-handler/build.gradle.kts b/orx-jvm/orx-crash-handler/build.gradle.kts new file mode 100644 index 00000000..cd9c26d3 --- /dev/null +++ b/orx-jvm/orx-crash-handler/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/orx-jvm/orx-crash-handler/src/demo/kotlin/DemoCrashHandler01.kt b/orx-jvm/orx-crash-handler/src/demo/kotlin/DemoCrashHandler01.kt new file mode 100644 index 00000000..a3cd08d5 --- /dev/null +++ b/orx-jvm/orx-crash-handler/src/demo/kotlin/DemoCrashHandler01.kt @@ -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") + } + } + } +} \ No newline at end of file diff --git a/orx-jvm/orx-crash-handler/src/main/kotlin/CrashHandler.kt b/orx-jvm/orx-crash-handler/src/main/kotlin/CrashHandler.kt new file mode 100644 index 00000000..7551f0b9 --- /dev/null +++ b/orx-jvm/orx-crash-handler/src/main/kotlin/CrashHandler.kt @@ -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() + + + 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) + } + } + +} \ No newline at end of file diff --git a/orx-jvm/orx-crash-handler/src/main/kotlin/Reporter.kt b/orx-jvm/orx-crash-handler/src/main/kotlin/Reporter.kt new file mode 100644 index 00000000..79e14d2f --- /dev/null +++ b/orx-jvm/orx-crash-handler/src/main/kotlin/Reporter.kt @@ -0,0 +1,5 @@ +package org.openrndr.extra.crashhandler + +abstract class Reporter(val handler: CrashHandler) { + abstract fun reportCrash(throwable: Throwable) +} \ No newline at end of file diff --git a/orx-jvm/orx-crash-handler/src/main/kotlin/SlackReporter.kt b/orx-jvm/orx-crash-handler/src/main/kotlin/SlackReporter.kt new file mode 100644 index 00000000..85b15ac3 --- /dev/null +++ b/orx-jvm/orx-crash-handler/src/main/kotlin/SlackReporter.kt @@ -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 = 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? = 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(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)) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e52b4437..3edaf4f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ include( "orx-color", "orx-composition", "orx-compositor", + "orx-jvm:orx-crash-handler", "orx-delegate-magic", "orx-jvm:orx-dnk3", "orx-easing",