Skip to content
Merged
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
94 changes: 94 additions & 0 deletions deploy/prod/grafana/dashboards/didit-ai-performance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"annotations": { "list": [] },
"editable": true,
"graphTooltip": 1,
"panels": [
{
"type": "timeseries",
"title": "AI Workflow p95 by Stage",
"id": 1,
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(didit_ai_workflow_duration_seconds_bucket{job=\"didit-api\"}[5m])) by (le, operation, stage))",
"legendFormat": "{{operation}} / {{stage}}",
"refId": "A"
}
],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "OpenAI Average and Maximum Duration",
"id": 2,
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [
{
"expr": "sum(rate(didit_openai_request_duration_seconds_sum{job=\"didit-api\",status=\"success\"}[5m])) by (operation) / sum(rate(didit_openai_request_duration_seconds_count{job=\"didit-api\",status=\"success\"}[5m])) by (operation)",
"legendFormat": "{{operation}} avg",
"refId": "A"
},
{
"expr": "max(didit_openai_request_duration_seconds_max{job=\"didit-api\"}) by (operation)",
"legendFormat": "{{operation}} max",
"refId": "B"
}
],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "AI Executor",
"id": 3,
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 8 },
"targets": [
{ "expr": "didit_ai_executor_active{job=\"didit-api\"}", "legendFormat": "active", "refId": "A" },
{ "expr": "didit_ai_executor_queue_size{job=\"didit-api\"}", "legendFormat": "queue", "refId": "B" },
{ "expr": "increase(didit_ai_executor_rejected_total{job=\"didit-api\"}[5m])", "legendFormat": "rejected", "refId": "C" }
]
},
{
"type": "timeseries",
"title": "Tomcat Threads",
"id": 4,
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 8 },
"targets": [
{ "expr": "tomcat_threads_busy_threads{job=\"didit-api\"}", "legendFormat": "busy", "refId": "A" },
{ "expr": "tomcat_threads_current_threads{job=\"didit-api\"}", "legendFormat": "current", "refId": "B" },
{ "expr": "tomcat_threads_config_max_threads{job=\"didit-api\"}", "legendFormat": "max", "refId": "C" }
]
},
{
"type": "timeseries",
"title": "Hikari Connections",
"id": 5,
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 8 },
"targets": [
{ "expr": "hikaricp_connections_active{job=\"didit-api\"}", "legendFormat": "active", "refId": "A" },
{ "expr": "hikaricp_connections_pending{job=\"didit-api\"}", "legendFormat": "pending", "refId": "B" }
]
},
{
"type": "timeseries",
"title": "OpenAI Tokens",
"id": 6,
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
"targets": [
{
"expr": "sum(increase(didit_openai_tokens_total{job=\"didit-api\"}[1h])) by (operation, type)",
"legendFormat": "{{operation}} / {{type}}",
"refId": "A"
}
]
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["didit", "ai", "performance"],
"templating": { "list": [] },
"time": { "from": "now-1h", "to": "now" },
"timezone": "browser",
"title": "DIDIT AI Performance",
"uid": "didit-ai-performance",
"version": 1
}
39 changes: 38 additions & 1 deletion src/main/kotlin/com/didit/adapter/config/AsyncConfig.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,45 @@
package com.didit.adapter.config

import io.micrometer.core.instrument.Gauge
import io.micrometer.core.instrument.MeterRegistry
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.ThreadPoolExecutor

@EnableAsync
@Configuration
class AsyncConfig
class AsyncConfig {
@Bean("taskExecutor")
fun taskExecutor(): ThreadPoolTaskExecutor =
ThreadPoolTaskExecutor().apply {
corePoolSize = 2
maxPoolSize = 4
queueCapacity = 100
setThreadNamePrefix("didit-async-")
}

@Bean("aiTaskExecutor")
fun aiTaskExecutor(meterRegistry: MeterRegistry): ThreadPoolTaskExecutor =
ThreadPoolTaskExecutor().apply {
corePoolSize = 4
maxPoolSize = 8
queueCapacity = 50
setThreadNamePrefix("didit-ai-")
setRejectedExecutionHandler(ThreadPoolExecutor.AbortPolicy())

Gauge.builder("didit.ai.executor.active", this) { it.activeCount.toDouble() }.register(meterRegistry)
Gauge
.builder("didit.ai.executor.queue.size", this) {
it.threadPoolExecutor.queue.size
.toDouble()
}.register(meterRegistry)
Gauge
.builder(
"didit.ai.executor.completed",
this,
) { it.threadPoolExecutor.completedTaskCount.toDouble() }
.register(meterRegistry)
}
}
64 changes: 9 additions & 55 deletions src/main/kotlin/com/didit/adapter/integration/ai/OpenAiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,22 @@ import com.didit.application.retrospect.required.GeneratedDeepQuestion
import com.didit.domain.shared.Job
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import org.springframework.web.client.body
import java.net.SocketTimeoutException

@Component
class OpenAiClient(
private val restClient: RestClient,
private val objectMapper: ObjectMapper,
private val feedbackPrompts: FeedbackPrompts,
private val meterRegistry: MeterRegistry,
private val metrics: OpenAiMetrics,
@param:Value("\${openai.api-key}") private val apiKey: String,
@param:Value("\${openai.chat.model}") private val model: String,
) : AIClient {
Expand All @@ -45,7 +39,7 @@ class OpenAiClient(

logger.debug("심화 질문 프롬프트 - job: $job, prompt:\n$prompt")

val result = callWithResult(prompt, "deep_question", deepQuestionSchema())
val result = callWithResult(prompt, "deep_question", "deep_question", deepQuestionSchema())

return parseDeepQuestion(result)
}
Expand All @@ -59,27 +53,25 @@ class OpenAiClient(

logger.debug("요약 프롬프트 - job: $job, prompt:\n$prompt")

val result = callWithResult(prompt, "retrospective_summary", summarySchema())
val result = callWithResult(prompt, "summary", "retrospective_summary", summarySchema())

return parseSummary(result)
}

private fun callWithResult(
prompt: String,
operation: String,
schemaName: String,
schema: Map<String, Any>,
): OpenAiResponse {
val operation = if (schemaName == "deep_question") "deep_question" else "summary"
val sample = Timer.start(meterRegistry)
var outcome = "success"

metrics.recordPromptCharacters(operation, prompt.length)
logger.info(
"OpenAI request started - operation: {}, transactionActive: {}",
operation,
TransactionSynchronizationManager.isActualTransactionActive(),
)

try {
return metrics.record(operation) {
val rawResponse =
restClient
.post()
Expand All @@ -106,50 +98,12 @@ class OpenAiClient(

logger.debug("OpenAI 전체 응답: $rawResponse")

return objectMapper.readValue<OpenAiResponse>(rawResponse)
} catch (exception: Exception) {
outcome = "error"
meterRegistry
.counter(
"didit.openai.request.errors",
"operation",
operation,
"type",
classifyException(exception),
).increment()
throw exception
} finally {
sample.stop(
Timer
.builder("didit.openai.request.duration")
.description("OpenAI API request duration")
.tag("operation", operation)
.tag("outcome", outcome)
.publishPercentileHistogram()
.register(meterRegistry),
)
objectMapper.readValue<OpenAiResponse>(rawResponse).also {
metrics.recordTokens(operation, it.usage?.inputTokens ?: 0, it.usage?.outputTokens ?: 0)
}
}
}

private fun classifyException(exception: Exception): String =
when (exception) {
is RestClientResponseException ->
when {
exception.statusCode.value() == 429 -> "rate_limit"
exception.statusCode.is4xxClientError -> "client_error"
exception.statusCode.is5xxServerError -> "server_error"
else -> "http_error"
}

is ResourceAccessException ->
if (exception.causeSequence().any { it is SocketTimeoutException }) "timeout" else "connection_error"

is JsonProcessingException -> "parse_error"
else -> "unknown"
}

private fun Throwable.causeSequence(): Sequence<Throwable> = generateSequence(this) { it.cause }

private fun parseDeepQuestion(response: OpenAiResponse): GeneratedDeepQuestion =
runCatching {
val question = objectMapper.readValue<DeepQuestionDto>(response.outputText).question
Expand Down
96 changes: 96 additions & 0 deletions src/main/kotlin/com/didit/adapter/integration/ai/OpenAiMetrics.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.didit.adapter.integration.ai

import com.fasterxml.jackson.core.JsonProcessingException
import io.micrometer.core.instrument.DistributionSummary
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import org.springframework.stereotype.Component
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClientResponseException
import java.net.SocketTimeoutException
import java.time.Duration

@Component
class OpenAiMetrics(
private val meterRegistry: MeterRegistry,
) {
fun <T> record(
operation: String,
block: () -> T,
): T {
val sample = Timer.start(meterRegistry)
try {
return block().also { stop(sample, operation, "success") }
} catch (exception: Exception) {
val errorType = classify(exception)
stop(sample, operation, if (errorType == "timeout") "timeout" else "error")
meterRegistry
.counter("didit.openai.request.errors", "operation", operation, "type", errorType)
.increment()
throw exception
}
}

fun recordPromptCharacters(
operation: String,
count: Int,
) {
DistributionSummary
.builder("didit.openai.prompt.characters")
.tag("operation", operation)
.register(meterRegistry)
.record(count.toDouble())
}

fun recordTokens(
operation: String,
inputTokens: Int,
outputTokens: Int,
) {
meterRegistry.counter("didit.openai.tokens", "operation", operation, "type", "input").increment(inputTokens.toDouble())
meterRegistry.counter("didit.openai.tokens", "operation", operation, "type", "output").increment(outputTokens.toDouble())
}

private fun stop(
sample: Timer.Sample,
operation: String,
status: String,
) {
sample.stop(
Timer
.builder("didit.openai.request.duration")
.tags("operation", operation, "status", status)
.publishPercentileHistogram()
.serviceLevelObjectives(
Duration.ofMillis(500),
Duration.ofSeconds(1),
Duration.ofSeconds(2),
Duration.ofSeconds(3),
Duration.ofSeconds(4),
Duration.ofSeconds(5),
Duration.ofMillis(7500),
Duration.ofSeconds(10),
Duration.ofSeconds(15),
).register(meterRegistry),
)
}

private fun classify(exception: Exception): String =
when (exception) {
is RestClientResponseException ->
when {
exception.statusCode.value() == 429 -> "rate_limit"
exception.statusCode.is4xxClientError -> "client_error"
exception.statusCode.is5xxServerError -> "server_error"
else -> "http_error"
}

is ResourceAccessException ->
if (exception.causeSequence().any { it is SocketTimeoutException }) "timeout" else "connection_error"

is JsonProcessingException -> "parse_error"
else -> "unknown"
}

private fun Throwable.causeSequence(): Sequence<Throwable> = generateSequence(this) { it.cause }
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ class RetrospectApi(
fun findAll(
@CurrentUserId userId: UUID,
): SuccessResponse<List<RetrospectiveListItemResponse>> {
val retrospectives = retrospectiveFinder.findAllByUserId(userId)
val retrospectives = retrospectiveFinder.findListItemsByUserId(userId)

return SuccessResponse.of(retrospectives.map { RetrospectiveListItemResponse.from(it) })
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.didit.adapter.webapi.retrospect.dto

import com.didit.application.retrospect.dto.RetrospectiveListItemResult
import com.didit.domain.retrospect.Retrospective
import java.time.LocalDateTime
import java.util.UUID
Expand All @@ -18,5 +19,13 @@ data class RetrospectiveListItemResponse(
summary = retrospective.summary?.summary,
completedAt = retrospective.completedAt,
)

fun from(result: RetrospectiveListItemResult): RetrospectiveListItemResponse =
RetrospectiveListItemResponse(
id = result.id,
title = result.title,
summary = result.summary,
completedAt = result.completedAt,
)
}
}
Loading
Loading