From ab2240c886c7c7a2746e7dcfa68f32cf64e73d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=A0=EB=89=B4?= Date: Sat, 1 Nov 2025 17:59:48 +0900 Subject: [PATCH 1/5] feature: add NOTIFY enums to seperate DiscordFeign channel --- domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt | 6 ++++++ domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt | 1 + 2 files changed, 7 insertions(+) create mode 100644 domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt diff --git a/domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt b/domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt new file mode 100644 index 00000000..568b3547 --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt @@ -0,0 +1,6 @@ +package com.dobby.gateway + +enum class AlertChannel { + ERRORS, + NOTIFY +} diff --git a/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt b/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt index 4c504309..b2975125 100644 --- a/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt +++ b/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt @@ -2,4 +2,5 @@ package com.dobby.gateway interface AlertGateway { fun sendError(e: Exception, requestUrl: String, clientIp: String?) + fun sendNotify(title: String, description: String, content: String?) } From 90d95bcf64769dc007f1bb35204807c2a45c7a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=A0=EB=89=B4?= Date: Sat, 1 Nov 2025 18:02:42 +0900 Subject: [PATCH 2/5] feature: apply MDC ThreadLocal pattern to capture matching summary logs to send discord message --- .../config/properties/DiscordProperties.kt | 14 +++++ .../feign/discord/DiscordFeignClient.kt | 14 ++++- .../gateway/discord/AlertGatewayImpl.kt | 54 +++++++++++++--- .../ExperimentPostCustomRepositoryImpl.kt | 13 +++- .../dobby/scheduler/SendMatchingEmailJob.kt | 63 ++++++++++++++++--- .../src/main/resources/application.yml | 8 ++- .../resources/template-application-local.yml | 8 ++- 7 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt diff --git a/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt b/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt new file mode 100644 index 00000000..20f29848 --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt @@ -0,0 +1,14 @@ +package com.dobby.config.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@ConfigurationProperties("discord") +data class DiscordProperties ( + val webhooks: Map = emptyMap() +) { + data class Webhook( + val id: String, + val token: String + ) +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt b/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt index 32b56801..5176de4d 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt @@ -3,15 +3,23 @@ package com.dobby.external.feign.discord import com.dobby.api.dto.request.DiscordMessageRequest import org.springframework.cloud.openfeign.FeignClient import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @FeignClient( name = "discord-alarm-feign-client", - url = "\${discord.webhook-url}" + url = "https://discord.com" ) interface DiscordFeignClient { - @PostMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) - fun sendMessage(@RequestBody discordMessageRequest: DiscordMessageRequest) + @PostMapping( + value = ["/api/webhooks/{id}/{token}"], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + fun sendMessage( + @PathVariable id: String, + @PathVariable token: String, + @RequestBody discordMessageRequest: DiscordMessageRequest + ) } diff --git a/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt index 91bd95af..ee71accc 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt @@ -1,7 +1,9 @@ package com.dobby.external.gateway.discord import com.dobby.api.dto.request.DiscordMessageRequest +import com.dobby.config.properties.DiscordProperties import com.dobby.external.feign.discord.DiscordFeignClient +import com.dobby.gateway.AlertChannel import com.dobby.gateway.AlertGateway import org.springframework.stereotype.Component import java.io.PrintWriter @@ -10,14 +12,35 @@ import java.time.LocalDateTime @Component class AlertGatewayImpl( + private val discordProperties: DiscordProperties, private val discordFeignClient: DiscordFeignClient ) : AlertGateway { + fun send(channel: AlertChannel, body: DiscordMessageRequest) { + val key = when (channel) { + AlertChannel.ERRORS -> "errors" + AlertChannel.NOTIFY -> "notify" + } + val hook = discordProperties.webhooks[key] ?: return + runCatching { + discordFeignClient.sendMessage(hook.id, hook.token, body) + }.onFailure { + } + } + override fun sendError(e: Exception, requestUrl: String, clientIp: String?) { - sendMessage(createMessage(e, requestUrl, clientIp)) + send(AlertChannel.ERRORS, createErrorMessage(e, requestUrl, clientIp)) + } + + override fun sendNotify( + title: String, + description: String, + content: String? + ) { + send(AlertChannel.NOTIFY, createNotifyMessage(title, description, content)) } - private fun createMessage(e: Exception, requestUrl: String, clientIp: String?): DiscordMessageRequest { + private fun createErrorMessage(e: Exception, requestUrl: String, clientIp: String?): DiscordMessageRequest { return DiscordMessageRequest( content = "# 🚨 μ—λŸ¬ λ°œμƒ 비이이이이사아아아앙", embeds = listOf( @@ -26,13 +49,13 @@ class AlertGatewayImpl( description = """ ### πŸ•– λ°œμƒ μ‹œκ°„ ${LocalDateTime.now()} - + ### πŸ”— μš”μ²­ URL $requestUrl - + ### πŸ–₯️ ν΄λΌμ΄μ–ΈνŠΈ IP ${clientIp ?: "μ•Œ 수 μ—†μŒ"} - + ### πŸ“„ Stack Trace ``` ${getStackTrace(e).substring(0, 1000)} @@ -42,14 +65,27 @@ class AlertGatewayImpl( ) ) } + fun createNotifyMessage( + title: String, + description: String, + content: String? = null + ): DiscordMessageRequest { + val embeds = listOf( + DiscordMessageRequest.Embed( + title = "πŸ“£ $title", + description = description + ) + ) + return DiscordMessageRequest( + content = content, + embeds = embeds + ) + } + private fun getStackTrace(e: Exception): String { val stringWriter = StringWriter() e.printStackTrace(PrintWriter(stringWriter)) return stringWriter.toString() } - - private fun sendMessage(request: DiscordMessageRequest) { - discordFeignClient.sendMessage(request) - } } diff --git a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt index 7c080c53..50ac625d 100644 --- a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt @@ -30,10 +30,12 @@ import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.slf4j.Logger import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.stereotype.Repository import java.time.LocalDate import java.time.LocalDateTime import java.time.Period +import java.time.format.DateTimeFormatter @Repository class ExperimentPostCustomRepositoryImpl( @@ -43,6 +45,9 @@ class ExperimentPostCustomRepositoryImpl( private lateinit var entityManager: EntityManager private val logger: Logger = LoggerFactory.getLogger(this::class.java) + companion object { + private val LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + } override fun findExperimentPostsByCustomFilter( customFilter: CustomFilter, @@ -263,7 +268,7 @@ class ExperimentPostCustomRepositoryImpl( ) .fetch() - logger.info("[쿼리 κ²°κ³Ό] participants count: {}", participants.size) + logger.info("[쿼리 κ²°κ³Ό] μ•Œλ¦Ό μˆ˜μ‹ μ— λ™μ˜ν•œ participants count: {}", participants.size) participants.take(10).forEachIndexed { index, tuple -> val participantEntity = tuple.get(participant) @@ -307,6 +312,12 @@ class ExperimentPostCustomRepositoryImpl( } }.toMap() + MDC.put("match.windowStart", lastProcessedTime.format(LOG_TIME_FORMATTER)) + MDC.put("match.windowEnd", currentTime.format(LOG_TIME_FORMATTER)) + MDC.put("match.todayPosts", todayPosts.size.toString()) + MDC.put("match.consentParticipants", participants.size.toString()) + MDC.put("match.matchedRecipients", resultMap.size.toString()) + logger.info("[μ΅œμ’… κ²°κ³Ό] 이메일을 받을 λŒ€μƒμž 수: {}", resultMap.size) return resultMap diff --git a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt index d8150934..ded3df25 100644 --- a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt +++ b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt @@ -1,5 +1,6 @@ package com.dobby.scheduler +import com.dobby.gateway.AlertGateway import com.dobby.service.EmailService import com.dobby.usecase.member.email.GetMatchingExperimentPostsUseCase import com.dobby.usecase.member.email.SendMatchingEmailUseCase @@ -8,21 +9,26 @@ import org.quartz.Job import org.quartz.JobExecutionContext import org.slf4j.Logger import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.stereotype.Component +import java.time.Duration +import java.time.LocalDate @Component class SendMatchingEmailJob( - private val emailService: EmailService + private val emailService: EmailService, + private val alertGateway: AlertGateway ) : Job { companion object { private val logger: Logger = LoggerFactory.getLogger(SendMatchingEmailJob::class.java) } override fun execute(context: JobExecutionContext) { - logger.info("BulkSendMatchingEmailJob started at ${TimeProvider.currentDateTime()}") + val start = TimeProvider.currentDateTime() + logger.info("BulkSendMatchingEmailJob started at $start") val input = GetMatchingExperimentPostsUseCase.Input( - requestTime = TimeProvider.currentDateTime() + requestTime = start ) val output = emailService.getMatchingInfo(input) val matchingExperimentPosts = output.matchingPosts @@ -32,25 +38,62 @@ class SendMatchingEmailJob( for ((contactEmail, jobList) in matchingExperimentPosts) { if (jobList.isEmpty()) continue + val emailInput = SendMatchingEmailUseCase.Input( contactEmail = contactEmail, experimentPosts = jobList, currentDateTime = TimeProvider.currentDateTime() ) - try { - val emailOutput = emailService.sendMatchingEmail(emailInput) - if (emailOutput.isSuccess) { + + runCatching { + emailService.sendMatchingEmail(emailInput) + }.onSuccess { result -> + if (result.isSuccess) { successCount++ logger.info("Email sent successfully to $contactEmail") } else { failureCount++ - logger.error("Failed to send email to $contactEmail: ${emailOutput.message}") + logger.error("Failed to send email to $contactEmail: ${result.message}") } - } catch (e: Exception) { + }.onFailure { e -> failureCount++ - logger.error("Exception occurred while sending email to $contactEmail", e) + logger.error("Exception while sending email to $contactEmail", e) } } - logger.info("SendMatchingEmailJob completed. Success: $successCount, Failures: $failureCount") + + val end = TimeProvider.currentDateTime() + val took = Duration.between(start, end).toSeconds() + + val windowStart = MDC.get("match.windowStart") ?: start.toString() + val windowEnd= MDC.get("match.windowEnd") ?: end.toString() + val todayCount= MDC.get("match.todayPosts")?.toIntOrNull() ?: 0 + val consentParticipants= MDC.get("match.consentParticipants")?.toIntOrNull() ?: 0 + val matchedRecipients = MDC.get("match.matchedRecipients")?.toIntOrNull() ?: matchingExperimentPosts.size + + listOf( + "match.windowStart","match.windowEnd","match.todayPosts", + "match.consentParticipants","match.matchedRecipients" + ).forEach { MDC.remove(it) } + + logger.info("SendMatchingEmailJob completed. Success=$successCount, Failures=$failureCount, Took=${took}s") + + val today = LocalDate.now() + + alertGateway.sendNotify( + title = "# πŸ“€ $today λ§€μΉ­ 메일 λ°œμ†‘ κ²°κ³Ό", + description = """ + **집계 ꡬ간**: $windowStart ~ $windowEnd (KST) + πŸ—“οΈ **였늘 올라온 곡고 수**: **$todayCount** λͺ… + πŸ‘€ **μ•Œλ¦Ό λ™μ˜ν•œ 전체 수**: **$consentParticipants** λͺ… + βœ‰οΈ **λ§€μΉ­ λ°œμ†‘λœ λŒ€μƒμž(이메일)**: **$matchedRecipients** λͺ… + + --- + βœ… **λ°œμ†‘ 성곡**: **$successCount** 건 + ❌ **λ°œμ†‘ μ‹€νŒ¨**: **$failureCount** 건 + ⏰ **μ‹€ν–‰ μ‹œκ°„**: ${took} 초 + πŸ•’ **μ™„λ£Œ μ‹œκ°**: $end + """.trimIndent(), + content = "@here" + ) } } diff --git a/infrastructure/src/main/resources/application.yml b/infrastructure/src/main/resources/application.yml index a710bfe4..3cd43703 100644 --- a/infrastructure/src/main/resources/application.yml +++ b/infrastructure/src/main/resources/application.yml @@ -78,7 +78,13 @@ cloud: secret-key: ${S3_SECRET_KEY} discord: - webhook-url: ${DISCORD_WEBHOOK_URL} + webhooks: + errors: + id: ${DISCORD_ERRORS_ID} + token: ${DISCORD_ERRORS_TOKEN} + notify: + id: ${DISCORD_NOTIFY_ID} + token: ${DISCORD_NOTIFY_TOKEN} cors: allowed-origins: diff --git a/infrastructure/src/main/resources/template-application-local.yml b/infrastructure/src/main/resources/template-application-local.yml index 4673a0b6..b0d7d417 100644 --- a/infrastructure/src/main/resources/template-application-local.yml +++ b/infrastructure/src/main/resources/template-application-local.yml @@ -81,7 +81,13 @@ cloud: secret-key: ${S3_SECRET_KEY} discord: - webhook-url: ${DISCORD_WEBHOOK_URL} + webhooks: + errors: + id: ${DISCORD_ERRORS_ID} + token: ${DISCORD_ERRORS_TOKEN} + notify: + id: ${DISCORD_NOTIFY_ID} + token: ${DISCORD_NOTIFY_TOKEN} cors: allowed-origins: From 32ce82cd7f7ab06cefe2470317ef79a8a70e6164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=A0=EB=89=B4?= Date: Sat, 1 Nov 2025 18:10:06 +0900 Subject: [PATCH 3/5] style: apply ktlintFormat --- .../config/properties/DiscordProperties.kt | 3 +-- .../gateway/discord/AlertGatewayImpl.kt | 1 - .../com/dobby/scheduler/SendMatchingEmailJob.kt | 17 ++++++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt b/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt index 20f29848..93f288ee 100644 --- a/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt +++ b/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt @@ -1,10 +1,9 @@ package com.dobby.config.properties import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.stereotype.Component @ConfigurationProperties("discord") -data class DiscordProperties ( +data class DiscordProperties( val webhooks: Map = emptyMap() ) { data class Webhook( diff --git a/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt index ee71accc..427c3678 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt @@ -82,7 +82,6 @@ class AlertGatewayImpl( ) } - private fun getStackTrace(e: Exception): String { val stringWriter = StringWriter() e.printStackTrace(PrintWriter(stringWriter)) diff --git a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt index ded3df25..05301e76 100644 --- a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt +++ b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt @@ -65,14 +65,17 @@ class SendMatchingEmailJob( val took = Duration.between(start, end).toSeconds() val windowStart = MDC.get("match.windowStart") ?: start.toString() - val windowEnd= MDC.get("match.windowEnd") ?: end.toString() - val todayCount= MDC.get("match.todayPosts")?.toIntOrNull() ?: 0 - val consentParticipants= MDC.get("match.consentParticipants")?.toIntOrNull() ?: 0 - val matchedRecipients = MDC.get("match.matchedRecipients")?.toIntOrNull() ?: matchingExperimentPosts.size + val windowEnd = MDC.get("match.windowEnd") ?: end.toString() + val todayCount = MDC.get("match.todayPosts")?.toIntOrNull() ?: 0 + val consentParticipants = MDC.get("match.consentParticipants")?.toIntOrNull() ?: 0 + val matchedRecipients = MDC.get("match.matchedRecipients")?.toIntOrNull() ?: matchingExperimentPosts.size listOf( - "match.windowStart","match.windowEnd","match.todayPosts", - "match.consentParticipants","match.matchedRecipients" + "match.windowStart", + "match.windowEnd", + "match.todayPosts", + "match.consentParticipants", + "match.matchedRecipients" ).forEach { MDC.remove(it) } logger.info("SendMatchingEmailJob completed. Success=$successCount, Failures=$failureCount, Took=${took}s") @@ -90,7 +93,7 @@ class SendMatchingEmailJob( --- βœ… **λ°œμ†‘ 성곡**: **$successCount** 건 ❌ **λ°œμ†‘ μ‹€νŒ¨**: **$failureCount** 건 - ⏰ **μ‹€ν–‰ μ‹œκ°„**: ${took} 초 + ⏰ **μ‹€ν–‰ μ‹œκ°„**: $took 초 πŸ•’ **μ™„λ£Œ μ‹œκ°**: $end """.trimIndent(), content = "@here" From 57d22af90f1eea64a057d8b265d65fb703869963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=A0=EB=89=B4?= Date: Sat, 1 Nov 2025 18:12:14 +0900 Subject: [PATCH 4/5] fix: fix count units --- .../src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt index 05301e76..5058f996 100644 --- a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt +++ b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt @@ -86,7 +86,7 @@ class SendMatchingEmailJob( title = "# πŸ“€ $today λ§€μΉ­ 메일 λ°œμ†‘ κ²°κ³Ό", description = """ **집계 ꡬ간**: $windowStart ~ $windowEnd (KST) - πŸ—“οΈ **였늘 올라온 곡고 수**: **$todayCount** λͺ… + πŸ—“οΈ **였늘 올라온 곡고 수**: **$todayCount** 건 πŸ‘€ **μ•Œλ¦Ό λ™μ˜ν•œ 전체 수**: **$consentParticipants** λͺ… βœ‰οΈ **λ§€μΉ­ λ°œμ†‘λœ λŒ€μƒμž(이메일)**: **$matchedRecipients** λͺ… From 714a2c617371ee3e353e404d4d7270121afafc8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=A0=EB=89=B4?= Date: Sat, 1 Nov 2025 18:16:19 +0900 Subject: [PATCH 5/5] style: format time info --- .../kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt index 5058f996..72ecca83 100644 --- a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt +++ b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt @@ -13,6 +13,7 @@ import org.slf4j.MDC import org.springframework.stereotype.Component import java.time.Duration import java.time.LocalDate +import java.time.format.DateTimeFormatter @Component class SendMatchingEmailJob( @@ -61,11 +62,13 @@ class SendMatchingEmailJob( } } - val end = TimeProvider.currentDateTime() - val took = Duration.between(start, end).toSeconds() + val rawEnd = TimeProvider.currentDateTime() + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val end = rawEnd.format(formatter) + val took = Duration.between(start, rawEnd).toSeconds() val windowStart = MDC.get("match.windowStart") ?: start.toString() - val windowEnd = MDC.get("match.windowEnd") ?: end.toString() + val windowEnd = MDC.get("match.windowEnd") ?: rawEnd.toString() val todayCount = MDC.get("match.todayPosts")?.toIntOrNull() ?: 0 val consentParticipants = MDC.get("match.consentParticipants")?.toIntOrNull() ?: 0 val matchedRecipients = MDC.get("match.matchedRecipients")?.toIntOrNull() ?: matchingExperimentPosts.size