diff --git a/settings.gradle.kts b/settings.gradle.kts index b7b3fcc..d970c14 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,5 @@ rootProject.name = "truffle-kotlin" include("truffle-core") +include("truffle-logback") include("truffle-spring-boot-starter") diff --git a/truffle-core/src/main/kotlin/Hub.kt b/truffle-core/src/main/kotlin/Hub.kt new file mode 100644 index 0000000..de997d3 --- /dev/null +++ b/truffle-core/src/main/kotlin/Hub.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.truffle.sdk.core + +import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent +import org.springframework.web.reactive.function.client.WebClient + +internal class Hub( + truffleOptions: TruffleOptions, + webClientBuilder: WebClient.Builder? = null, +) : IHub { + private val client: TruffleClient = DefaultTruffleClient( + apiKey = truffleOptions.apiKey, + webClientBuilder = webClientBuilder ?: WebClient.builder(), + ) + + override fun captureEvent(truffleEvent: TruffleEvent) { + client.sendEvent(truffleEvent) + } +} diff --git a/truffle-core/src/main/kotlin/IHub.kt b/truffle-core/src/main/kotlin/IHub.kt new file mode 100644 index 0000000..595368b --- /dev/null +++ b/truffle-core/src/main/kotlin/IHub.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.truffle.sdk.core + +import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent + +interface IHub { + fun captureEvent(truffleEvent: TruffleEvent) +} diff --git a/truffle-core/src/main/kotlin/Truffle.kt b/truffle-core/src/main/kotlin/Truffle.kt new file mode 100644 index 0000000..8e3bb09 --- /dev/null +++ b/truffle-core/src/main/kotlin/Truffle.kt @@ -0,0 +1,40 @@ +package com.wafflestudio.truffle.sdk.core + +import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent +import org.springframework.web.reactive.function.client.WebClient + +object Truffle { + private lateinit var hub: IHub + + internal object HubAdapter : IHub { + override fun captureEvent(truffleEvent: TruffleEvent) { + Truffle.captureEvent(truffleEvent) + } + } + + // for modules without access WebClient.Builder + fun init(truffleOptions: TruffleOptions): IHub { + return init(truffleOptions, null) + } + + @Synchronized fun init(truffleOptions: TruffleOptions, webClientBuilder: WebClient.Builder? = null): IHub { + if (::hub.isInitialized) { + return hub + } + + validateConfig(truffleOptions) + + this.hub = Hub(truffleOptions, webClientBuilder) + return HubAdapter + } + + private fun captureEvent(truffleEvent: TruffleEvent) { + hub.captureEvent(truffleEvent) + } + + private fun validateConfig(truffleOptions: TruffleOptions) { + if (truffleOptions.apiKey.isBlank()) { + throw IllegalArgumentException("Truffle API key is blank") + } + } +} diff --git a/truffle-core/src/main/kotlin/TruffleClient.kt b/truffle-core/src/main/kotlin/TruffleClient.kt index 6570df2..4d2fb08 100644 --- a/truffle-core/src/main/kotlin/TruffleClient.kt +++ b/truffle-core/src/main/kotlin/TruffleClient.kt @@ -1,9 +1,6 @@ package com.wafflestudio.truffle.sdk.core -import com.wafflestudio.truffle.sdk.core.protocol.TruffleApp import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent -import com.wafflestudio.truffle.sdk.core.protocol.TruffleException -import com.wafflestudio.truffle.sdk.core.protocol.TruffleRuntime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher @@ -16,25 +13,21 @@ import org.springframework.web.reactive.function.client.bodyToMono import java.time.Duration import java.util.concurrent.Executors -interface TruffleClient { - fun sendEvent(ex: Throwable) +internal interface TruffleClient { + fun sendEvent(truffleEvent: TruffleEvent) } -class DefaultTruffleClient( - name: String, - phase: String, +internal class DefaultTruffleClient( apiKey: String, webClientBuilder: WebClient.Builder, ) : TruffleClient { private val events = MutableSharedFlow(extraBufferCapacity = 10) - private val logger = LoggerFactory.getLogger(javaClass) - private val truffleApp = TruffleApp(name, phase) - private val truffleRuntime = TruffleRuntime(name = "Java", version = System.getProperty("java.version")) - init { - val coroutineScope = CoroutineScope(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) + val coroutineScope = CoroutineScope( + Executors.newSingleThreadExecutor { r -> Thread(r, "truffle-client") }.asCoroutineDispatcher() + ) val webClient = webClientBuilder .baseUrl("https://truffle-api.wafflestudio.com") .defaultHeader("x-api-key", apiKey) @@ -58,15 +51,7 @@ class DefaultTruffleClient( } } - override fun sendEvent(ex: Throwable) { - if (truffleApp.phase == "local" || truffleApp.phase == "test") return - - events.tryEmit( - TruffleEvent( - app = truffleApp, - runtime = truffleRuntime, - exception = TruffleException(ex), - ) - ) + override fun sendEvent(truffleEvent: TruffleEvent) { + events.tryEmit(truffleEvent) } } diff --git a/truffle-core/src/main/kotlin/TruffleOptions.kt b/truffle-core/src/main/kotlin/TruffleOptions.kt new file mode 100644 index 0000000..4f01faa --- /dev/null +++ b/truffle-core/src/main/kotlin/TruffleOptions.kt @@ -0,0 +1,17 @@ +package com.wafflestudio.truffle.sdk.core + +import ch.qos.logback.classic.Level + +open class TruffleOptions { + /** + * Truffle 서버에서 애플리케이션의 요청이 유효한지 검증하는 데에 사용하는 API key. + * + * 외부에 공개되지 않도록 주의해 관리해야 합니다. + */ + open lateinit var apiKey: String + + /** + * truffle logback 사용 시 이벤트를 전송할 최소 로그 레벨. + */ + open var minimumLevel: Level = Level.ERROR +} diff --git a/truffle-core/src/main/kotlin/protocol/TruffleApp.kt b/truffle-core/src/main/kotlin/protocol/TruffleApp.kt deleted file mode 100644 index 0c92c56..0000000 --- a/truffle-core/src/main/kotlin/protocol/TruffleApp.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.wafflestudio.truffle.sdk.core.protocol - -data class TruffleApp( - val name: String, - val phase: String, -) diff --git a/truffle-core/src/main/kotlin/protocol/TruffleAppInfo.kt b/truffle-core/src/main/kotlin/protocol/TruffleAppInfo.kt new file mode 100644 index 0000000..0dcc13e --- /dev/null +++ b/truffle-core/src/main/kotlin/protocol/TruffleAppInfo.kt @@ -0,0 +1,10 @@ +package com.wafflestudio.truffle.sdk.core.protocol + +object TruffleAppInfo { + val runtime = TruffleRuntime() + + data class TruffleRuntime( + val name: String = "Java", + val version: String = System.getProperty("java.version") + ) +} diff --git a/truffle-core/src/main/kotlin/protocol/TruffleEvent.kt b/truffle-core/src/main/kotlin/protocol/TruffleEvent.kt index 0b0b80e..1fda871 100644 --- a/truffle-core/src/main/kotlin/protocol/TruffleEvent.kt +++ b/truffle-core/src/main/kotlin/protocol/TruffleEvent.kt @@ -2,7 +2,7 @@ package com.wafflestudio.truffle.sdk.core.protocol data class TruffleEvent( val version: String = TruffleVersion.V1, - val app: TruffleApp, - val runtime: TruffleRuntime, + val runtime: TruffleAppInfo.TruffleRuntime = TruffleAppInfo.runtime, + val level: TruffleLevel, val exception: TruffleException, ) diff --git a/truffle-core/src/main/kotlin/protocol/TruffleLevel.kt b/truffle-core/src/main/kotlin/protocol/TruffleLevel.kt new file mode 100644 index 0000000..94041e5 --- /dev/null +++ b/truffle-core/src/main/kotlin/protocol/TruffleLevel.kt @@ -0,0 +1,23 @@ +package com.wafflestudio.truffle.sdk.core.protocol + +import ch.qos.logback.classic.Level + +enum class TruffleLevel { + DEBUG, + INFO, + WARNING, + ERROR, + FATAL, + ; + + companion object { + fun from(level: Level): TruffleLevel { + return when { + level.isGreaterOrEqual(Level.ERROR) -> ERROR + level.isGreaterOrEqual(Level.WARN) -> WARNING + level.isGreaterOrEqual(Level.INFO) -> INFO + else -> DEBUG + } + } + } +} diff --git a/truffle-core/src/main/kotlin/protocol/TruffleRuntime.kt b/truffle-core/src/main/kotlin/protocol/TruffleRuntime.kt deleted file mode 100644 index 281725d..0000000 --- a/truffle-core/src/main/kotlin/protocol/TruffleRuntime.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.wafflestudio.truffle.sdk.core.protocol - -data class TruffleRuntime( - val name: String, - val version: String, -) diff --git a/truffle-logback/build.gradle.kts b/truffle-logback/build.gradle.kts new file mode 100644 index 0000000..c3847c4 --- /dev/null +++ b/truffle-logback/build.gradle.kts @@ -0,0 +1,5 @@ +dependencies { + compileOnly("ch.qos.logback:logback-classic") + + implementation(project(":truffle-core")) +} diff --git a/truffle-logback/src/main/kotlin/appender/TruffleAppender.kt b/truffle-logback/src/main/kotlin/appender/TruffleAppender.kt new file mode 100644 index 0000000..5c2db14 --- /dev/null +++ b/truffle-logback/src/main/kotlin/appender/TruffleAppender.kt @@ -0,0 +1,53 @@ +package com.wafflestudio.truffle.sdk.logback + +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.ThrowableProxy +import ch.qos.logback.core.UnsynchronizedAppenderBase +import com.wafflestudio.truffle.sdk.core.IHub +import com.wafflestudio.truffle.sdk.core.Truffle +import com.wafflestudio.truffle.sdk.core.TruffleOptions +import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent +import com.wafflestudio.truffle.sdk.core.protocol.TruffleException +import com.wafflestudio.truffle.sdk.core.protocol.TruffleLevel + +class TruffleAppender : UnsynchronizedAppenderBase() { + lateinit var options: TruffleOptions + private lateinit var hub: IHub + + override fun start() { + hub = Truffle.init(options) + super.start() + } + + override fun append(eventObject: ILoggingEvent) { + if (eventObject.level.isGreaterOrEqual(options.minimumLevel) && + !eventObject.loggerName.startsWith("com.wafflestudio.truffle.sdk") + ) { + val truffleEvent = createEvent(eventObject) + hub.captureEvent(truffleEvent) + } + } + + private fun createEvent(eventObject: ILoggingEvent): TruffleEvent { + val exception = eventObject.throwableProxy?.let { + TruffleException((it as ThrowableProxy).throwable) + } ?: TruffleException( + className = eventObject.loggerName, + message = eventObject.formattedMessage, + elements = eventObject.callerData.map { + TruffleException.Element( + className = it.className, + methodName = it.methodName, + fileName = it.fileName ?: "", + lineNumber = it.lineNumber, + isInAppInclude = true, // FIXME + ) + }, + ) + + return TruffleEvent( + level = TruffleLevel.from(eventObject.level), + exception = exception, + ) + } +} diff --git a/truffle-spring-boot-starter/build.gradle.kts b/truffle-spring-boot-starter/build.gradle.kts index e53d1fd..054dfb9 100644 --- a/truffle-spring-boot-starter/build.gradle.kts +++ b/truffle-spring-boot-starter/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") implementation(project(":truffle-core")) + compileOnly(project(":truffle-logback")) } diff --git a/truffle-spring-boot-starter/src/main/kotlin/TruffleAutoConfiguration.kt b/truffle-spring-boot-starter/src/main/kotlin/TruffleAutoConfiguration.kt index d6fbbfa..f61a425 100644 --- a/truffle-spring-boot-starter/src/main/kotlin/TruffleAutoConfiguration.kt +++ b/truffle-spring-boot-starter/src/main/kotlin/TruffleAutoConfiguration.kt @@ -1,9 +1,13 @@ package com.wafflestudio.truffle.sdk -import com.wafflestudio.truffle.sdk.core.DefaultTruffleClient -import com.wafflestudio.truffle.sdk.core.TruffleClient +import ch.qos.logback.classic.LoggerContext +import com.wafflestudio.truffle.sdk.core.IHub +import com.wafflestudio.truffle.sdk.core.Truffle +import com.wafflestudio.truffle.sdk.logback.TruffleAppender import com.wafflestudio.truffle.sdk.reactive.TruffleWebExceptionHandler import com.wafflestudio.truffle.sdk.servlet.TruffleHandlerExceptionResolver +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean @@ -13,14 +17,20 @@ import org.springframework.web.server.WebExceptionHandler import org.springframework.web.servlet.HandlerExceptionResolver @EnableConfigurationProperties(TruffleProperties::class) +@ConditionalOnProperty(value = ["truffle.enabled"], havingValue = "true") @Configuration class TruffleAutoConfiguration { + @Bean + fun truffleHub(properties: TruffleProperties, webClientBuilder: WebClient.Builder): IHub { + return Truffle.init(properties, webClientBuilder) + } + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @Configuration class TruffleServletConfiguration { @Bean - fun truffleHandlerExceptionResolver(truffleClient: TruffleClient): HandlerExceptionResolver { - return TruffleHandlerExceptionResolver(truffleClient) + fun truffleHandlerExceptionResolver(truffleHub: IHub): HandlerExceptionResolver { + return TruffleHandlerExceptionResolver(truffleHub) } } @@ -28,17 +38,18 @@ class TruffleAutoConfiguration { @Configuration class TruffleReactiveConfiguration { @Bean - fun truffleWebExceptionHandler(truffleClient: TruffleClient): WebExceptionHandler { - return TruffleWebExceptionHandler(truffleClient) + fun truffleWebExceptionHandler(truffleHub: IHub): WebExceptionHandler { + return TruffleWebExceptionHandler(truffleHub) } } - @Bean - fun truffleClient(properties: TruffleProperties, webClientBuilder: WebClient.Builder): TruffleClient = - DefaultTruffleClient( - name = properties.name, - phase = properties.phase, - apiKey = properties.apiKey, - webClientBuilder = webClientBuilder, - ) + @ConditionalOnClass(value = [LoggerContext::class, TruffleAppender::class]) + @ConditionalOnProperty(value = ["truffle.logback.enabled"], havingValue = "true", matchIfMissing = true) + @Configuration + class TruffleLogbackConfiguration { + @Bean + fun truffleLogbackInitializer(truffleProperties: TruffleProperties): TruffleLogbackInitializer { + return TruffleLogbackInitializer(truffleProperties) + } + } } diff --git a/truffle-spring-boot-starter/src/main/kotlin/TruffleLogbackInitializer.kt b/truffle-spring-boot-starter/src/main/kotlin/TruffleLogbackInitializer.kt new file mode 100644 index 0000000..dd36e89 --- /dev/null +++ b/truffle-spring-boot-starter/src/main/kotlin/TruffleLogbackInitializer.kt @@ -0,0 +1,43 @@ +package com.wafflestudio.truffle.sdk + +import ch.qos.logback.classic.LoggerContext +import com.wafflestudio.truffle.sdk.logback.TruffleAppender +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEvent +import org.springframework.context.event.ContextRefreshedEvent +import org.springframework.context.event.GenericApplicationListener +import org.springframework.core.ResolvableType +import ch.qos.logback.classic.Logger as LogbackLogger + +class TruffleLogbackInitializer( + private val truffleProperties: TruffleProperties, +) : GenericApplicationListener { + override fun supportsEventType(eventType: ResolvableType): Boolean { + return eventType.rawClass?.let { ContextRefreshedEvent::class.java.isAssignableFrom(it) } ?: false + } + + override fun onApplicationEvent(event: ApplicationEvent) { + val rootLogger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as LogbackLogger + + if (!isTruffleAppenderRegistered(rootLogger)) { + val truffleAppender = TruffleAppender() + truffleAppender.name = "TRUFFLE_APPENDER" + truffleAppender.context = LoggerFactory.getILoggerFactory() as LoggerContext + truffleAppender.options = truffleProperties + + truffleAppender.start() + rootLogger.addAppender(truffleAppender) + } + } + + private fun isTruffleAppenderRegistered(logger: LogbackLogger): Boolean { + val iterator = logger.iteratorForAppenders() + while (iterator.hasNext()) { + if (iterator.next() is TruffleAppender) { + return true + } + } + return false + } +} diff --git a/truffle-spring-boot-starter/src/main/kotlin/TruffleProperties.kt b/truffle-spring-boot-starter/src/main/kotlin/TruffleProperties.kt index f1569aa..9378641 100644 --- a/truffle-spring-boot-starter/src/main/kotlin/TruffleProperties.kt +++ b/truffle-spring-boot-starter/src/main/kotlin/TruffleProperties.kt @@ -1,26 +1,20 @@ package com.wafflestudio.truffle.sdk +import ch.qos.logback.classic.Level +import com.wafflestudio.truffle.sdk.core.TruffleOptions import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties("truffle.client") data class TruffleProperties( - /** - * Truffle 이 식별하는 애플리케이션의 이름. - * - * 에러 리포트에 사용되며 Truffle 서버에 등록된 이름과 일치하는 정확한 애플리케이션 이름이 사용되어야 합니다. - */ - val name: String, - /** - * 애플리케이션의 환경을 구분하는 이름. - * - * 에러 리포트에 사용되며 `"prod"`, `"dev"`, `"local"` 등이 사용될 수 있습니다. - * `"local"` 또는 `"test"`가 사용되는 경우, Truffle SDK 는 Truffle 서버로 요청을 전송하지 않습니다. - */ - val phase: String, /** * Truffle 서버에서 애플리케이션의 요청이 유효한지 검증하는 데에 사용하는 API key. * * 외부에 공개되지 않도록 주의해 관리해야 합니다. */ - val apiKey: String, -) + override var apiKey: String, + + /** + * truffle logback 사용 시 이벤트를 전송할 최소 로그 레벨. + */ + override var minimumLevel: Level = Level.ERROR, +) : TruffleOptions() diff --git a/truffle-spring-boot-starter/src/main/kotlin/reactive/TruffleWebExceptionHandler.kt b/truffle-spring-boot-starter/src/main/kotlin/reactive/TruffleWebExceptionHandler.kt index a864145..294b5ac 100644 --- a/truffle-spring-boot-starter/src/main/kotlin/reactive/TruffleWebExceptionHandler.kt +++ b/truffle-spring-boot-starter/src/main/kotlin/reactive/TruffleWebExceptionHandler.kt @@ -1,6 +1,9 @@ package com.wafflestudio.truffle.sdk.reactive -import com.wafflestudio.truffle.sdk.core.TruffleClient +import com.wafflestudio.truffle.sdk.core.IHub +import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent +import com.wafflestudio.truffle.sdk.core.protocol.TruffleException +import com.wafflestudio.truffle.sdk.core.protocol.TruffleLevel import org.springframework.core.annotation.Order import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ServerWebExchange @@ -8,12 +11,15 @@ import org.springframework.web.server.WebExceptionHandler import reactor.core.publisher.Mono @Order(-2) -class TruffleWebExceptionHandler( - private val truffleClient: TruffleClient, -) : WebExceptionHandler { +class TruffleWebExceptionHandler(private val hub: IHub) : WebExceptionHandler { override fun handle(exchange: ServerWebExchange, ex: Throwable): Mono { if (ex !is ResponseStatusException) { - truffleClient.sendEvent(ex) + hub.captureEvent( + TruffleEvent( + level = TruffleLevel.FATAL, + exception = TruffleException(ex), + ) + ) } return Mono.error(ex) diff --git a/truffle-spring-boot-starter/src/main/kotlin/servlet/TruffleHandlerExceptionResolver.kt b/truffle-spring-boot-starter/src/main/kotlin/servlet/TruffleHandlerExceptionResolver.kt index d0b79ae..2185afa 100644 --- a/truffle-spring-boot-starter/src/main/kotlin/servlet/TruffleHandlerExceptionResolver.kt +++ b/truffle-spring-boot-starter/src/main/kotlin/servlet/TruffleHandlerExceptionResolver.kt @@ -1,18 +1,18 @@ package com.wafflestudio.truffle.sdk.servlet -import com.wafflestudio.truffle.sdk.core.TruffleClient +import com.wafflestudio.truffle.sdk.core.IHub +import com.wafflestudio.truffle.sdk.core.protocol.TruffleEvent +import com.wafflestudio.truffle.sdk.core.protocol.TruffleException +import com.wafflestudio.truffle.sdk.core.protocol.TruffleLevel import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.core.annotation.Order import org.springframework.web.server.ResponseStatusException import org.springframework.web.servlet.HandlerExceptionResolver import org.springframework.web.servlet.ModelAndView -import java.lang.Exception @Order(-2) -class TruffleHandlerExceptionResolver( - private val truffleClient: TruffleClient, -) : HandlerExceptionResolver { +class TruffleHandlerExceptionResolver(private val hub: IHub) : HandlerExceptionResolver { override fun resolveException( request: HttpServletRequest, response: HttpServletResponse, @@ -20,7 +20,12 @@ class TruffleHandlerExceptionResolver( ex: Exception, ): ModelAndView? { if (ex !is ResponseStatusException) { - truffleClient.sendEvent(ex) + hub.captureEvent( + TruffleEvent( + level = TruffleLevel.FATAL, + exception = TruffleException(ex), + ) + ) } return null diff --git a/truffle-spring-boot-starter/src/test/resources/application.yml b/truffle-spring-boot-starter/src/test/resources/application.yml index 873f26f..a4a0644 100644 --- a/truffle-spring-boot-starter/src/test/resources/application.yml +++ b/truffle-spring-boot-starter/src/test/resources/application.yml @@ -1,5 +1,4 @@ truffle: + enabled: true client: - name: snutt - phase: dev api-key: abcdef