Skip to content

Commit

Permalink
Implement Slack integration (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamkobor authored Aug 18, 2020
1 parent fad42c7 commit a584df1
Show file tree
Hide file tree
Showing 19 changed files with 551 additions and 27 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ Kuvasz (pronounce as [ˈkuvɒs]) is an ancient hungarian breed of livestock & gu

- Uptime & latency monitoring with a configurable interval
- Email notifications through SMTP
- Slack notifications through webhoooks

### Under development 🚧

- SSL certification monitoring
- Regular Lighthouse audits for your websites
- Pagerduty, Opsgenie, Slack integration
- Pagerduty, Opsgenie integration
- Kuvasz Dashboard, a standalone GUI

## ⚡️ Quick start guide
Expand Down
Binary file added docs/kuvasz_avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions examples/docker-compose/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ services:
SMTP_FROM_ADDRESS: '[email protected]'
SMTP_TO_ADDRESS: '[email protected]'
SMTP_TRANSPORT_STRATEGY: 'SMTP_TLS'
ENABLE_SLACK_EVENT_HANDLER: 'true'
SLACK_WEBHOOK_URL: 'https://your.slack-webhook.url'
2 changes: 2 additions & 0 deletions examples/k8s/kuvasz.configmap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ data:
smtp_from_address: "[email protected]"
smtp_to_address: "[email protected]"
smtp_transport_strategy: "SMTP_TLS"
slack_event_handler_enabled: "true"
slack_webhook_url: "https://your.slack-webhook.url"
10 changes: 10 additions & 0 deletions examples/k8s/kuvasz.deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,13 @@ spec:
configMapKeyRef:
name: kuvasz-config
key: smtp_to_address
- name: ENABLE_SLACK_EVENT_HANDLER
valueFrom:
configMapKeyRef:
name: kuvasz-config
key: slack_event_handler_enabled
- name: SLACK_WEBHOOK_URL
valueFrom:
configMapKeyRef:
name: kuvasz-config
key: slack_webhook_url
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kuvaszuptime.kuvasz.config.handlers

import com.kuvaszuptime.kuvasz.models.dto.Validation.URI_REGEX
import io.micronaut.context.annotation.ConfigurationProperties
import io.micronaut.core.annotation.Introspected
import javax.inject.Singleton
import javax.validation.constraints.Pattern

@ConfigurationProperties("handler-config.slack-event-handler")
@Singleton
@Introspected
class SlackEventHandlerConfig {
@Pattern(regexp = URI_REGEX)
var webhookUrl: String? = null
}
25 changes: 23 additions & 2 deletions src/main/kotlin/com/kuvaszuptime/kuvasz/factories/EmailFactory.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.kuvaszuptime.kuvasz.factories

import com.kuvaszuptime.kuvasz.config.handlers.EmailEventHandlerConfig
import com.kuvaszuptime.kuvasz.models.MonitorDownEvent
import com.kuvaszuptime.kuvasz.models.MonitorUpEvent
import com.kuvaszuptime.kuvasz.models.UptimeMonitorEvent
import com.kuvaszuptime.kuvasz.models.toMessage
import com.kuvaszuptime.kuvasz.models.toEmoji
import com.kuvaszuptime.kuvasz.models.toStructuredMessage
import com.kuvaszuptime.kuvasz.models.toUptimeStatus
import org.simplejavamail.api.email.Email
import org.simplejavamail.email.EmailBuilder
Expand All @@ -16,11 +19,29 @@ class EmailFactory(private val config: EmailEventHandlerConfig) {
.buildEmail()

private fun UptimeMonitorEvent.getSubject(): String =
"[kuvasz-uptime] - [${monitor.name}] ${monitor.url} is ${toUptimeStatus()}"
"[kuvasz-uptime] - ${toEmoji()} [${monitor.name}] ${monitor.url} is ${toUptimeStatus()}"

private fun createEmailBase() =
EmailBuilder
.startingBlank()
.to(config.to, config.to)
.from(config.from, config.from)

private fun UptimeMonitorEvent.toMessage() =
when (this) {
is MonitorUpEvent -> toStructuredMessage().let { details ->
listOfNotNull(
details.summary,
details.latency,
details.previousDownTime.orNull()
)
}
is MonitorDownEvent -> toStructuredMessage().let { details ->
listOfNotNull(
details.summary,
details.error,
details.previousUpTime.orNull()
)
}
}.joinToString("\n")
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.kuvaszuptime.kuvasz.handlers

import com.kuvaszuptime.kuvasz.models.RedirectEvent
import com.kuvaszuptime.kuvasz.models.toMessage
import com.kuvaszuptime.kuvasz.models.toPlainMessage
import com.kuvaszuptime.kuvasz.services.EventDispatcher
import io.micronaut.context.annotation.Context
import io.micronaut.context.annotation.Requires
Expand All @@ -17,10 +17,10 @@ class LogEventHandler @Inject constructor(eventDispatcher: EventDispatcher) {

init {
eventDispatcher.subscribeToMonitorUpEvents { event ->
logger.info(event.toMessage())
logger.info(event.toPlainMessage())
}
eventDispatcher.subscribeToMonitorDownEvents { event ->
logger.error(event.toMessage())
logger.error(event.toPlainMessage())
}
eventDispatcher.subscribeToRedirectEvents { event ->
logger.warn(event.toLogMessage())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.kuvaszuptime.kuvasz.handlers

import com.kuvaszuptime.kuvasz.models.MonitorDownEvent
import com.kuvaszuptime.kuvasz.models.MonitorUpEvent
import com.kuvaszuptime.kuvasz.models.SlackWebhookMessage
import com.kuvaszuptime.kuvasz.models.UptimeMonitorEvent
import com.kuvaszuptime.kuvasz.models.runWhenStateChanges
import com.kuvaszuptime.kuvasz.models.toEmoji
import com.kuvaszuptime.kuvasz.models.toStructuredMessage
import com.kuvaszuptime.kuvasz.services.EventDispatcher
import com.kuvaszuptime.kuvasz.services.SlackWebhookService
import io.micronaut.context.annotation.Context
import io.micronaut.context.annotation.Requires
import io.micronaut.http.HttpResponse
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.scheduling.TaskExecutors
import io.micronaut.scheduling.annotation.ExecuteOn
import io.reactivex.Flowable
import org.slf4j.LoggerFactory
import javax.inject.Inject

@Context
@Requires(property = "handler-config.slack-event-handler.enabled", value = "true")
class SlackEventHandler @Inject constructor(
private val slackWebhookService: SlackWebhookService,
private val eventDispatcher: EventDispatcher
) {
companion object {
private val logger = LoggerFactory.getLogger(SlackEventHandler::class.java)
}

init {
subscribeToEvents()
}

@ExecuteOn(TaskExecutors.IO)
private fun subscribeToEvents() {
eventDispatcher.subscribeToMonitorUpEvents { event ->
logger.debug("A MonitorUpEvent has been received for monitor with ID: ${event.monitor.id}")
event.runWhenStateChanges { slackWebhookService.sendMessage(it.toSlackMessage()).handleResponse() }
}
eventDispatcher.subscribeToMonitorDownEvents { event ->
logger.debug("A MonitorDownEvent has been received for monitor with ID: ${event.monitor.id}")
event.runWhenStateChanges { slackWebhookService.sendMessage(it.toSlackMessage()).handleResponse() }
}
}

private fun UptimeMonitorEvent.toSlackMessage() = SlackWebhookMessage(text = "${toEmoji()} ${toMessage()}")

private fun Flowable<HttpResponse<String>>.handleResponse() =
subscribe(
{
logger.debug("A Slack message to your configured webhook has been successfully sent")
},
{ ex ->
if (ex is HttpClientResponseException) {
val responseBody = ex.response.getBody(String::class.java)
logger.error("Slack message cannot be sent to your configured webhook: $responseBody")
}
}
)

private fun UptimeMonitorEvent.toMessage() =
when (this) {
is MonitorUpEvent -> toStructuredMessage().let { details ->
listOfNotNull(
"*${details.summary}*",
"_${details.latency}_",
details.previousDownTime.orNull()
)
}
is MonitorDownEvent -> toStructuredMessage().let { details ->
listOfNotNull(
"*${details.summary}*",
"_${details.error}_",
details.previousUpTime.orNull()
)
}
}.joinToString("\n")
}
6 changes: 6 additions & 0 deletions src/main/kotlin/com/kuvaszuptime/kuvasz/models/Emoji.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.kuvaszuptime.kuvasz.models

object Emoji {
const val ALERT = "🚨"
const val CHECK_OK = ""
}
60 changes: 44 additions & 16 deletions src/main/kotlin/com/kuvaszuptime/kuvasz/models/Event.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,30 @@ data class RedirectEvent(
val redirectLocation: URI
) : Event()

data class StructuredUpMessage(
val summary: String,
val latency: String,
val previousDownTime: Option<String>
)

data class StructuredDownMessage(
val summary: String,
val error: String,
val previousUpTime: Option<String>
)

fun UptimeMonitorEvent.toUptimeStatus(): UptimeStatus =
when (this) {
is MonitorUpEvent -> UptimeStatus.UP
is MonitorDownEvent -> UptimeStatus.DOWN
}

fun UptimeMonitorEvent.toEmoji(): String =
when (this) {
is MonitorUpEvent -> Emoji.CHECK_OK
is MonitorDownEvent -> Emoji.ALERT
}

fun UptimeMonitorEvent.uptimeStatusEquals(previousEvent: UptimeEventPojo) =
toUptimeStatus() == previousEvent.status

Expand Down Expand Up @@ -73,24 +91,34 @@ fun UptimeMonitorEvent.runWhenStateChanges(toRun: (UptimeMonitorEvent) -> Unit)
)
}

fun UptimeMonitorEvent.toMessage(): String =
when (this) {
is MonitorUpEvent -> toMessage()
is MonitorDownEvent -> toMessage()
fun MonitorUpEvent.toPlainMessage(): String =
toStructuredMessage().let { details ->
listOfNotNull(
details.summary,
details.latency,
details.previousDownTime.orNull()
).joinToString(". ")
}

fun MonitorUpEvent.toMessage(): String {
val message = "Your monitor \"${monitor.name}\" (${monitor.url}) is UP (${status.code}). Latency was: ${latency}ms."
return getEndedEventDuration().toDurationString().fold(
{ message },
{ "$message Was down for $it." }
fun MonitorUpEvent.toStructuredMessage() =
StructuredUpMessage(
summary = "Your monitor \"${monitor.name}\" (${monitor.url}) is UP (${status.code})",
latency = "Latency: ${latency}ms",
previousDownTime = getEndedEventDuration().toDurationString().map { "Was down for $it" }
)
}

fun MonitorDownEvent.toMessage(): String {
val message = "Your monitor \"${monitor.name}\" (${monitor.url}) is DOWN. Reason: ${error.message}."
return getEndedEventDuration().toDurationString().fold(
{ message },
{ "$message Was up for $it." }
fun MonitorDownEvent.toPlainMessage(): String =
toStructuredMessage().let { details ->
listOfNotNull(
details.summary,
details.error,
details.previousUpTime.orNull()
).joinToString(". ")
}

fun MonitorDownEvent.toStructuredMessage() =
StructuredDownMessage(
summary = "Your monitor \"${monitor.name}\" (${monitor.url}) is DOWN",
error = "Reason: ${error.message}",
previousUpTime = getEndedEventDuration().toDurationString().map { "Was up for $it" }
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kuvaszuptime.kuvasz.models

import com.fasterxml.jackson.annotation.JsonProperty
import java.net.URI

data class SlackWebhookMessage(
val username: String = "KuvaszBot",
@JsonProperty("icon_url")
val iconUrl: URI = URI(ICON_URL),
val text: String
) {
companion object {
const val ICON_URL = "https://raw.githubusercontent.com/kuvasz-uptime/kuvasz/main/docs/kuvasz_avatar.png"
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.kuvaszuptime.kuvasz.models.dto

internal object Validation {
object Validation {
const val MIN_UPTIME_CHECK_INTERVAL = 60L
const val URI_REGEX = "^(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.kuvaszuptime.kuvasz.services

import com.kuvaszuptime.kuvasz.config.handlers.SlackEventHandlerConfig
import com.kuvaszuptime.kuvasz.models.SlackWebhookMessage
import io.micronaut.context.event.ShutdownEvent
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.client.RxHttpClient
import io.micronaut.runtime.event.annotation.EventListener
import io.reactivex.Flowable
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SlackWebhookService @Inject constructor(
private val slackEventHandlerConfig: SlackEventHandlerConfig,
private val httpClient: RxHttpClient
) {

companion object {
private const val RETRY_COUNT = 3L
}

fun sendMessage(message: SlackWebhookMessage): Flowable<HttpResponse<String>> {
val request: HttpRequest<SlackWebhookMessage> = HttpRequest.POST(slackEventHandlerConfig.webhookUrl, message)

return httpClient
.exchange(request, Argument.STRING, Argument.STRING)
.retry(RETRY_COUNT)
}

@EventListener
@Suppress("UNUSED_PARAMETER")
internal fun onShutdownEvent(event: ShutdownEvent) {
httpClient.close()
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ handler-config:
enabled: ${ENABLE_SMTP_EVENT_HANDLER:`false`}
from: ${SMTP_FROM_ADDRESS}
to: ${SMTP_TO_ADDRESS}
slack-event-handler:
enabled: ${ENABLE_SLACK_EVENT_HANDLER:`false`}
webhook-url: ${SLACK_WEBHOOK_URL}
---
admin-auth:
username: ${ADMIN_USER}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import io.micronaut.context.env.PropertySource
import io.micronaut.context.exceptions.BeanInstantiationException

class SMTPMailerConfigTest : BehaviorSpec({
given("an SMTPMailerConfigTest bean") {
given("an SMTPMailerConfig bean") {
`when`("the SMTP host does not exists") {
val properties = PropertySource.of(
"test",
Expand Down
Loading

0 comments on commit a584df1

Please sign in to comment.