Skip to content
Open
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
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ dependencies {
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.server.logging)

// Metrics
implementation(libs.ktor.server.metrics.micrometer)
implementation(libs.micrometer.registry.prometheus)

// Serialization
implementation(libs.ktor.server.serialization)
implementation(libs.kotlinx.serialization.json)
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ wire-sdk-version = "0.2.1"
redis-version = "6.7.1.RELEASE"
mustache-version = "0.9.14"
mockk-version = "1.14.11"
micrometer-version = "1.16.0"
shadow = "9.6.1"

[plugins]
Expand All @@ -29,6 +30,9 @@ ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor-version" }
ktor-server-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-version" }
ktor-server-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor-version" }
# Pinned to the version koin-ktor already forces onto ktor-server-core; 3.2.3 is binary incompatible with it.
ktor-server-metrics-micrometer = { module = "io.ktor:ktor-server-metrics-micrometer", version = "3.4.0" }
micrometer-registry-prometheus = { module = "io.micrometer:micrometer-registry-prometheus", version.ref = "micrometer-version" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin-version" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-version" }
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/wire/github/Application.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.wire.github

import com.wire.github.config.projectModules
import com.wire.github.metrics.configureMetrics
import com.wire.github.util.ENV_VAR_PORT
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
Expand All @@ -22,4 +23,5 @@ fun main() {

fun Application.module() {
configureRouting()
configureMetrics()
}
4 changes: 4 additions & 0 deletions src/main/kotlin/com/wire/github/EventsHandler.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.wire.github

import com.wire.github.metrics.UsageMetrics
import com.wire.github.util.ENV_VAR_HOST
import com.wire.github.util.SessionIdentifierGenerator
import com.wire.github.util.toStorageKey
Expand All @@ -16,6 +17,7 @@ class EventsHandler : WireEventsHandlerSuspending() {
private val logger = LoggerFactory.getLogger(this::class.java)
private val redisConnection = GlobalContext.get().get<StatefulRedisConnection<String, String>>()
private val storage = redisConnection.sync()
private val usageMetrics = GlobalContext.get().get<UsageMetrics>()

override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) {
if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) {
Expand All @@ -24,6 +26,7 @@ class EventsHandler : WireEventsHandlerSuspending() {
"conversationId: ${wireMessage.conversationId}, " +
"senderId: ${wireMessage.sender}"
)
usageMetrics.onHelpCommand()
val message = formatSetupInstructions(
conversationId = wireMessage.conversationId,
secret = storage.get(wireMessage.conversationId.toStorageKey())
Expand All @@ -50,6 +53,7 @@ class EventsHandler : WireEventsHandlerSuspending() {
"Event received. Event: AppAddedToConversation, " +
"conversationId: ${conversation.id}"
)
usageMetrics.onAppAddedToConversation()
val message = buildString {
appendLine(WELCOME_TEXT)
appendLine(formatSetupInstructions(conversationId = conversation.id))
Expand Down
61 changes: 46 additions & 15 deletions src/main/kotlin/com/wire/github/Routing.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.wire.github

import com.wire.github.metrics.UsageMetrics
import com.wire.github.response.model.GitHubResponse
import com.wire.github.util.KtxSerializer
import com.wire.github.util.SignatureValidator
Expand All @@ -20,6 +21,7 @@ import io.ktor.server.routing.application
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.io.IOException
import java.util.UUID
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
Expand All @@ -35,6 +37,7 @@ fun Application.configureRouting() {
val wireAppSdk = GlobalContext.get().get<WireAppSdk>()
val signatureValidator = GlobalContext.get().get<SignatureValidator>()
val templateHandler = GlobalContext.get().get<TemplateHandler>()
val usageMetrics = GlobalContext.get().get<UsageMetrics>()

routing {
trace {
Expand Down Expand Up @@ -72,19 +75,40 @@ fun Application.configureRouting() {
val payload = call.receiveText()

// Validation of received signature
val isSignatureValid = signatureValidator.isValid(
conversationId = conversationId,
conversationDomain = conversationDomain,
signature = signature,
payload = payload
)
val isSignatureValid = try {
signatureValidator.isValid(
conversationId = conversationId,
conversationDomain = conversationDomain,
signature = signature,
payload = payload
)
} catch (exception: IOException) {
application.log.warn(
"No secret stored for conversation $conversationId@$conversationDomain, " +
"rejecting $event delivery $delivery",
exception
)

// A missing secret can never validate on retry, so this is a permanent
// rejection (403) rather than a server error (500) GitHub would redeliver.
return@post call.respond(
status = HttpStatusCode.Forbidden,
message = "Invalid Signature for Conversation"
)
}
if (!isSignatureValid) {
application.log.warn(
"Invalid signature for conversation $conversationId@$conversationDomain, " +
"rejecting $event delivery $delivery"
)
return@post call.respond(
status = HttpStatusCode.Forbidden,
message = "Invalid Signature for Conversation"
)
}

usageMetrics.onWebhookEventReceived(event = event)

val response = try {
KtxSerializer.json.decodeFromString<GitHubResponse>(payload)
} catch (exception: SerializationException) {
Expand All @@ -98,18 +122,25 @@ fun Application.configureRouting() {
response = response
)

messageTemplate?.let { message ->
wireAppSdk.getApplicationManager().sendMessage(
message = WireMessage.Text.create(
conversationId = QualifiedId(
id = UUID.fromString(conversationId),
domain = conversationDomain
),
text = message
)
if (messageTemplate == null) {
usageMetrics.onUnsupportedEvent(
event = event,
action = response.action
)
return@post call.response.status(HttpStatusCode.OK)
}

wireAppSdk.getApplicationManager().sendMessage(
message = WireMessage.Text.create(
conversationId = QualifiedId(
id = UUID.fromString(conversationId),
domain = conversationDomain
),
text = messageTemplate
)
)
usageMetrics.onNotificationSent(event = event)

return@post call.response.status(HttpStatusCode.OK)
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/com/wire/github/config/Modules.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.wire.github.config

import com.wire.github.EventsHandler
import com.wire.github.metrics.UsageMetrics
import com.wire.github.util.ENV_VAR_API_HOST
import com.wire.github.util.ENV_VAR_API_TOKEN
import com.wire.github.util.ENV_VAR_APPLICATION_ID
Expand All @@ -12,6 +13,8 @@ import com.wire.sdk.WireAppSdk
import io.ktor.utils.io.core.toByteArray
import io.lettuce.core.RedisClient
import io.lettuce.core.api.StatefulRedisConnection
import io.micrometer.prometheusmetrics.PrometheusConfig
import io.micrometer.prometheusmetrics.PrometheusMeterRegistry
import org.koin.dsl.module

val projectModules = module {
Expand All @@ -24,6 +27,8 @@ val projectModules = module {
single { TemplateHandler() }
single { RedisClient.create(ENV_VAR_REDIS_URL) }
single<StatefulRedisConnection<String, String>> { get<RedisClient>().connect() }
single { PrometheusMeterRegistry(PrometheusConfig.DEFAULT) }
single { UsageMetrics(registry = get<PrometheusMeterRegistry>()) }
}

private fun wireAppSdk(): WireAppSdk =
Expand Down
24 changes: 24 additions & 0 deletions src/main/kotlin/com/wire/github/metrics/Metrics.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.wire.github.metrics

import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.metrics.micrometer.MicrometerMetrics
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.micrometer.prometheusmetrics.PrometheusMeterRegistry
import org.koin.core.context.GlobalContext

fun Application.configureMetrics() {
val prometheusRegistry = GlobalContext.get().get<PrometheusMeterRegistry>()

install(plugin = MicrometerMetrics) {
registry = prometheusRegistry
}

routing {
get("/metrics") {
call.respond(prometheusRegistry.scrape())
}
}
}
92 changes: 92 additions & 0 deletions src/main/kotlin/com/wire/github/metrics/UsageMetrics.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.wire.github.metrics

import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry

class UsageMetrics(
private val registry: MeterRegistry
) {
private val helpCommandCounter: Counter = Counter
.builder("githubapp_help_commands_total")
.description("Number of Help commands received")
.register(registry)

private val appAddedToConversationCounter: Counter = Counter
.builder("githubapp_added_to_conversation_total")
.description("Number of times the app is added to a conversation")
.register(registry)

private fun webhookEventsReceivedCounter(event: String): Counter =
Counter
.builder("githubapp_webhook_events_received_total")
.description("Number of GitHub webhook deliveries accepted for processing")
.tag(TAG_EVENT, event)
.register(registry)

private fun notificationsSentCounter(event: String): Counter =
Counter
.builder("githubapp_notifications_sent_total")
.description("Number of messages sent to a conversation for a webhook delivery")
.tag(TAG_EVENT, event)
.register(registry)

private fun unsupportedEventsCounter(
event: String,
action: String?
): Counter =
Counter
.builder("githubapp_unsupported_events_total")
.description("Number of webhook deliveries with no matching message template")
.tag(TAG_EVENT, event)
.tag(TAG_ACTION, action ?: NO_ACTION)
.register(registry)

/**
* A help command was received in a conversation.
* Signals how often users come back for the setup instructions.
*/
fun onHelpCommand() {
helpCommandCounter.increment()
}

/**
* The app was added to a conversation.
* First step of onboarding, before any webhook is configured.
*/
fun onAppAddedToConversation() {
appAddedToConversationCounter.increment()
}

/**
* A webhook delivery passed signature validation and entered processing.
* Top of the delivery funnel.
*/
fun onWebhookEventReceived(event: String) {
webhookEventsReceivedCounter(event).increment()
}

/**
* A webhook delivery was rendered and sent to the conversation.
* Bottom of the delivery funnel.
*/
fun onNotificationSent(event: String) {
notificationsSentCounter(event).increment()
}

/**
* A webhook delivery had no matching message template, so nothing was sent.
* Ranks the event/action pairs worth adding templates for.
*/
fun onUnsupportedEvent(
event: String,
action: String?
) {
unsupportedEventsCounter(event, action).increment()
}

private companion object {
const val TAG_EVENT = "event"
const val TAG_ACTION = "action"
const val NO_ACTION = "none"
}
}
Loading
Loading