feat(server,cli): observability metrics as a replayable projection + correx stats
Implements observability-spec §2 + §3-tier-1: metrics as a pure fold over the
event log, no third pillar, no OTel. Every metric is replayable over the full
historical log because it derives from events other contexts already emit — no
new EventPayload subclasses, so no serialization registry change.
- MetricsProjection: Projection<MetricsState> folding inference/tool/approval/
stage/workflow events into raw sums; approval request→decision latency is
correlated in-fold via a transient pending-request map.
- MetricsInspectionService: rebuilds via DefaultEventReplayer, derives read-side
ratios (token throughput, avg approval wait, session wall-time accounting split
into inference / tool / approval-wait / other) into MetricsReport.
- GET /sessions/{id}/metrics route, mirroring the replay endpoint.
- `correx stats <sessionId>` CLI client (--json passthrough), mirroring replay.
- Tests: seeded-event fold correctness with controlled timestamps (7) + CLI
render smoke tests (4).
This commit is contained in:
@@ -6,6 +6,7 @@ import com.correx.apps.cli.commands.ProviderCommand
|
|||||||
import com.correx.apps.cli.commands.ReplayCommand
|
import com.correx.apps.cli.commands.ReplayCommand
|
||||||
import com.correx.apps.cli.commands.RunCommand
|
import com.correx.apps.cli.commands.RunCommand
|
||||||
import com.correx.apps.cli.commands.SessionCommand
|
import com.correx.apps.cli.commands.SessionCommand
|
||||||
|
import com.correx.apps.cli.commands.StatsCommand
|
||||||
import com.correx.apps.cli.commands.StatusCommand
|
import com.correx.apps.cli.commands.StatusCommand
|
||||||
import com.correx.apps.cli.commands.UndoCommand
|
import com.correx.apps.cli.commands.UndoCommand
|
||||||
import com.github.ajalt.clikt.core.CliktCommand
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
@@ -29,4 +30,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
|
|||||||
UndoCommand(),
|
UndoCommand(),
|
||||||
EventsCommand(),
|
EventsCommand(),
|
||||||
ReplayCommand(),
|
ReplayCommand(),
|
||||||
|
StatsCommand(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.correx.apps.cli.commands
|
||||||
|
|
||||||
|
import com.correx.apps.cli.CorrexCli
|
||||||
|
import com.correx.apps.cli.DEFAULT_PORT
|
||||||
|
import com.github.ajalt.clikt.core.CliktCommand
|
||||||
|
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||||
|
import com.github.ajalt.clikt.parameters.options.default
|
||||||
|
import com.github.ajalt.clikt.parameters.options.option
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FailureMetricsDto(
|
||||||
|
val inferenceFailures: Long = 0,
|
||||||
|
val inferenceTimeouts: Long = 0,
|
||||||
|
val toolFailures: Long = 0,
|
||||||
|
val toolRejections: Long = 0,
|
||||||
|
val stageFailures: Long = 0,
|
||||||
|
val workflowFailures: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProviderStatsDto(
|
||||||
|
val provider: String,
|
||||||
|
val completedCount: Long,
|
||||||
|
val totalLatencyMs: Long,
|
||||||
|
val promptTokens: Long,
|
||||||
|
val completionTokens: Long,
|
||||||
|
val tokensPerSecond: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolStatsDto(
|
||||||
|
val toolName: String,
|
||||||
|
val completedCount: Long,
|
||||||
|
val totalDurationMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TierApprovalStatsDto(
|
||||||
|
val tier: String,
|
||||||
|
val requestedCount: Long,
|
||||||
|
val resolvedCount: Long,
|
||||||
|
val totalWaitMs: Long,
|
||||||
|
val avgWaitMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatsReportDto(
|
||||||
|
val sessionId: String,
|
||||||
|
val eventCount: Long = 0,
|
||||||
|
val sessionDurationMs: Long = 0,
|
||||||
|
val inferenceCount: Long = 0,
|
||||||
|
val inferenceMs: Long = 0,
|
||||||
|
val promptTokens: Long = 0,
|
||||||
|
val completionTokens: Long = 0,
|
||||||
|
val tokensPerSecond: Double = 0.0,
|
||||||
|
val perProvider: List<ProviderStatsDto> = emptyList(),
|
||||||
|
val toolCount: Long = 0,
|
||||||
|
val toolMs: Long = 0,
|
||||||
|
val perTool: List<ToolStatsDto> = emptyList(),
|
||||||
|
val approvalsRequested: Long = 0,
|
||||||
|
val approvalsResolved: Long = 0,
|
||||||
|
val approvalsPending: Long = 0,
|
||||||
|
val approvalWaitMs: Long = 0,
|
||||||
|
val avgApprovalWaitMs: Long = 0,
|
||||||
|
val perTier: List<TierApprovalStatsDto> = emptyList(),
|
||||||
|
val failures: FailureMetricsDto = FailureMetricsDto(),
|
||||||
|
val inferencePct: Double = 0.0,
|
||||||
|
val toolPct: Double = 0.0,
|
||||||
|
val approvalWaitPct: Double = 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MS_PER_SECOND = 1000L
|
||||||
|
private const val SECONDS_PER_MINUTE = 60L
|
||||||
|
private const val MINUTES_PER_HOUR = 60L
|
||||||
|
|
||||||
|
private fun humanDuration(ms: Long): String {
|
||||||
|
if (ms <= 0L) return "0s"
|
||||||
|
val totalSeconds = ms / MS_PER_SECOND
|
||||||
|
val hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)
|
||||||
|
val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR
|
||||||
|
val seconds = totalSeconds % SECONDS_PER_MINUTE
|
||||||
|
return buildString {
|
||||||
|
if (hours > 0) append("${hours}h ")
|
||||||
|
if (hours > 0 || minutes > 0) append("${minutes}m ")
|
||||||
|
append("${seconds}s")
|
||||||
|
}.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun otherPct(report: StatsReportDto): Double =
|
||||||
|
(100.0 - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0)
|
||||||
|
|
||||||
|
fun renderStats(report: StatsReportDto): String {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
lines += "Session: ${report.sessionId}"
|
||||||
|
lines += "Events: ${report.eventCount}"
|
||||||
|
lines += "Duration: ${humanDuration(report.sessionDurationMs)}"
|
||||||
|
lines += ""
|
||||||
|
|
||||||
|
lines += "Inference"
|
||||||
|
lines += " calls: ${report.inferenceCount}"
|
||||||
|
lines += " tokens: ${report.promptTokens} prompt + ${report.completionTokens} completion = " +
|
||||||
|
"${report.promptTokens + report.completionTokens}"
|
||||||
|
lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(report.tokensPerSecond)
|
||||||
|
for (p in report.perProvider) {
|
||||||
|
lines += " %-20s %4d calls %6d tok %8d ms (%.1f tok/s)".format(
|
||||||
|
p.provider, p.completedCount, p.promptTokens + p.completionTokens, p.totalLatencyMs, p.tokensPerSecond,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lines += ""
|
||||||
|
|
||||||
|
lines += "Tools"
|
||||||
|
lines += " calls: ${report.toolCount} time: ${report.toolMs} ms"
|
||||||
|
for (t in report.perTool) {
|
||||||
|
lines += " %-24s %4d %8d ms".format(t.toolName, t.completedCount, t.totalDurationMs)
|
||||||
|
}
|
||||||
|
lines += ""
|
||||||
|
|
||||||
|
lines += "Approvals"
|
||||||
|
lines += " requested: ${report.approvalsRequested} resolved: ${report.approvalsResolved} " +
|
||||||
|
"pending: ${report.approvalsPending}"
|
||||||
|
lines += " total wait: ${report.approvalWaitMs} ms (avg ${report.avgApprovalWaitMs} ms)"
|
||||||
|
for (tier in report.perTier) {
|
||||||
|
lines += " %-4s req %d res %d wait %d ms (avg %d ms)".format(
|
||||||
|
tier.tier, tier.requestedCount, tier.resolvedCount, tier.totalWaitMs, tier.avgWaitMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lines += ""
|
||||||
|
|
||||||
|
val f = report.failures
|
||||||
|
lines += "Failures"
|
||||||
|
lines += " inference: ${f.inferenceFailures} timeouts: ${f.inferenceTimeouts} " +
|
||||||
|
"tool: ${f.toolFailures} rejected: ${f.toolRejections} " +
|
||||||
|
"stage: ${f.stageFailures} workflow: ${f.workflowFailures}"
|
||||||
|
lines += ""
|
||||||
|
|
||||||
|
lines += "Time accounting (% of session wall time)"
|
||||||
|
lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format(
|
||||||
|
report.inferencePct, report.toolPct, report.approvalWaitPct, otherPct(report),
|
||||||
|
)
|
||||||
|
|
||||||
|
return lines.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsCommand : CliktCommand(name = "stats") {
|
||||||
|
private val sessionId by argument("sessionId")
|
||||||
|
private val host by option("--host").default("localhost")
|
||||||
|
private val port by option("--port").default("$DEFAULT_PORT")
|
||||||
|
|
||||||
|
private val statsJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override fun run(): Unit = runBlocking {
|
||||||
|
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
|
||||||
|
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
|
||||||
|
val url = "http://$host:$portInt/sessions/$sessionId/metrics"
|
||||||
|
|
||||||
|
val client = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(statsJson) }
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
if (outputJson) {
|
||||||
|
println(client.get(url).bodyAsText())
|
||||||
|
} else {
|
||||||
|
println(renderStats(client.get(url).body()))
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
System.err.println("Error fetching stats: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.correx.apps.cli.commands
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class StatsRenderTest {
|
||||||
|
|
||||||
|
private val report = StatsReportDto(
|
||||||
|
sessionId = "abc-123",
|
||||||
|
eventCount = 12,
|
||||||
|
sessionDurationMs = 90_000,
|
||||||
|
inferenceCount = 3,
|
||||||
|
inferenceMs = 6000,
|
||||||
|
promptTokens = 310,
|
||||||
|
completionTokens = 170,
|
||||||
|
tokensPerSecond = 28.3,
|
||||||
|
perProvider = listOf(
|
||||||
|
ProviderStatsDto("lfm:a", 2, 5000, 300, 150, 30.0),
|
||||||
|
ProviderStatsDto("lfm:b", 1, 1000, 10, 20, 20.0),
|
||||||
|
),
|
||||||
|
toolCount = 3,
|
||||||
|
toolMs = 1500,
|
||||||
|
perTool = listOf(ToolStatsDto("shell.read", 2, 1200)),
|
||||||
|
approvalsRequested = 2,
|
||||||
|
approvalsResolved = 1,
|
||||||
|
approvalsPending = 1,
|
||||||
|
approvalWaitMs = 30_000,
|
||||||
|
avgApprovalWaitMs = 30_000,
|
||||||
|
perTier = listOf(TierApprovalStatsDto("T2", 2, 1, 30_000, 30_000)),
|
||||||
|
failures = FailureMetricsDto(inferenceFailures = 1, toolFailures = 1, workflowFailures = 1),
|
||||||
|
inferencePct = 6.7,
|
||||||
|
toolPct = 1.7,
|
||||||
|
approvalWaitPct = 33.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `render includes session id and all section headers`() {
|
||||||
|
val out = renderStats(report)
|
||||||
|
assertTrue(out.contains("Session: abc-123"))
|
||||||
|
assertTrue(out.contains("Inference"))
|
||||||
|
assertTrue(out.contains("Tools"))
|
||||||
|
assertTrue(out.contains("Approvals"))
|
||||||
|
assertTrue(out.contains("Failures"))
|
||||||
|
assertTrue(out.contains("Time accounting"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `render shows per-provider and per-tier breakdown rows`() {
|
||||||
|
val out = renderStats(report)
|
||||||
|
assertTrue(out.contains("lfm:a"), "provider row missing")
|
||||||
|
assertTrue(out.contains("lfm:b"), "provider row missing")
|
||||||
|
assertTrue(out.contains("shell.read"), "tool row missing")
|
||||||
|
assertTrue(out.contains("T2"), "tier row missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `render formats duration as human-readable`() {
|
||||||
|
// 90_000 ms -> 1m 30s
|
||||||
|
assertTrue(renderStats(report).contains("1m 30s"), "duration not humanized")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time accounting other is the remainder of the three tracked categories`() {
|
||||||
|
// 100 - 6.7 - 1.7 - 33.3 = 58.3
|
||||||
|
assertTrue(renderStats(report).contains("other: 58.3%"), "remainder miscomputed")
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
package com.correx.apps.server.metrics
|
||||||
|
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
private const val MS_PER_SECOND = 1000.0
|
||||||
|
private const val PERCENT = 100.0
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProviderStatsDto(
|
||||||
|
val provider: String,
|
||||||
|
val completedCount: Long,
|
||||||
|
val totalLatencyMs: Long,
|
||||||
|
val promptTokens: Long,
|
||||||
|
val completionTokens: Long,
|
||||||
|
val tokensPerSecond: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolStatsDto(
|
||||||
|
val toolName: String,
|
||||||
|
val completedCount: Long,
|
||||||
|
val totalDurationMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TierApprovalStatsDto(
|
||||||
|
val tier: String,
|
||||||
|
val requestedCount: Long,
|
||||||
|
val resolvedCount: Long,
|
||||||
|
val totalWaitMs: Long,
|
||||||
|
val avgWaitMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-friendly view of a session's metrics. Raw sums plus the derived ratios the operator
|
||||||
|
* actually reads; the time-accounting percentages split session wall time into inference /
|
||||||
|
* tool execution / awaiting-approval, with the remainder ("other": context build, orchestration,
|
||||||
|
* idle) recoverable as `100 − (inference + tool + approvalWait)`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MetricsReport(
|
||||||
|
val sessionId: String,
|
||||||
|
val eventCount: Long,
|
||||||
|
val sessionDurationMs: Long,
|
||||||
|
val inferenceCount: Long,
|
||||||
|
val inferenceMs: Long,
|
||||||
|
val promptTokens: Long,
|
||||||
|
val completionTokens: Long,
|
||||||
|
val tokensPerSecond: Double,
|
||||||
|
val perProvider: List<ProviderStatsDto>,
|
||||||
|
val toolCount: Long,
|
||||||
|
val toolMs: Long,
|
||||||
|
val perTool: List<ToolStatsDto>,
|
||||||
|
val approvalsRequested: Long,
|
||||||
|
val approvalsResolved: Long,
|
||||||
|
val approvalsPending: Long,
|
||||||
|
val approvalWaitMs: Long,
|
||||||
|
val avgApprovalWaitMs: Long,
|
||||||
|
val perTier: List<TierApprovalStatsDto>,
|
||||||
|
val failures: FailureMetrics,
|
||||||
|
val inferencePct: Double,
|
||||||
|
val toolPct: Double,
|
||||||
|
val approvalWaitPct: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds [MetricsState] by replaying a session through [MetricsProjection], then derives the
|
||||||
|
* read-side ratios into a [MetricsReport]. Replay-only: no network, no live inference
|
||||||
|
* (Hard Invariant #8).
|
||||||
|
*/
|
||||||
|
class MetricsInspectionService(private val eventStore: EventStore) {
|
||||||
|
|
||||||
|
private val replayer = DefaultEventReplayer(eventStore, MetricsProjection())
|
||||||
|
|
||||||
|
fun inspect(sessionId: SessionId): MetricsReport {
|
||||||
|
val state = replayer.rebuild(sessionId)
|
||||||
|
return toReport(sessionId, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toReport(sessionId: SessionId, s: MetricsState): MetricsReport {
|
||||||
|
val durationMs = sessionDurationMs(s)
|
||||||
|
return MetricsReport(
|
||||||
|
sessionId = sessionId.value,
|
||||||
|
eventCount = s.eventCount,
|
||||||
|
sessionDurationMs = durationMs,
|
||||||
|
inferenceCount = s.inference.completedCount,
|
||||||
|
inferenceMs = s.inference.totalLatencyMs,
|
||||||
|
promptTokens = s.inference.promptTokens,
|
||||||
|
completionTokens = s.inference.completionTokens,
|
||||||
|
tokensPerSecond = tokensPerSecond(s.inference.completionTokens, s.inference.totalLatencyMs),
|
||||||
|
perProvider = s.inference.perProvider.entries
|
||||||
|
.sortedByDescending { it.value.totalLatencyMs }
|
||||||
|
.map { (provider, m) ->
|
||||||
|
ProviderStatsDto(
|
||||||
|
provider = provider,
|
||||||
|
completedCount = m.completedCount,
|
||||||
|
totalLatencyMs = m.totalLatencyMs,
|
||||||
|
promptTokens = m.promptTokens,
|
||||||
|
completionTokens = m.completionTokens,
|
||||||
|
tokensPerSecond = tokensPerSecond(m.completionTokens, m.totalLatencyMs),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
toolCount = s.tools.completedCount,
|
||||||
|
toolMs = s.tools.totalDurationMs,
|
||||||
|
perTool = s.tools.perTool.entries
|
||||||
|
.sortedByDescending { it.value.totalDurationMs }
|
||||||
|
.map { (name, m) -> ToolStatsDto(name, m.completedCount, m.totalDurationMs) },
|
||||||
|
approvalsRequested = s.approvals.requestedCount,
|
||||||
|
approvalsResolved = s.approvals.resolvedCount,
|
||||||
|
approvalsPending = s.pendingApprovals.size.toLong(),
|
||||||
|
approvalWaitMs = s.approvals.totalWaitMs,
|
||||||
|
avgApprovalWaitMs = avg(s.approvals.totalWaitMs, s.approvals.resolvedCount),
|
||||||
|
perTier = s.approvals.perTier.entries
|
||||||
|
.sortedBy { it.key }
|
||||||
|
.map { (tier, m) ->
|
||||||
|
TierApprovalStatsDto(
|
||||||
|
tier = tier,
|
||||||
|
requestedCount = m.requestedCount,
|
||||||
|
resolvedCount = m.resolvedCount,
|
||||||
|
totalWaitMs = m.totalWaitMs,
|
||||||
|
avgWaitMs = avg(m.totalWaitMs, m.resolvedCount),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
failures = s.failures,
|
||||||
|
inferencePct = pct(s.inference.totalLatencyMs, durationMs),
|
||||||
|
toolPct = pct(s.tools.totalDurationMs, durationMs),
|
||||||
|
approvalWaitPct = pct(s.approvals.totalWaitMs, durationMs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionDurationMs(s: MetricsState): Long {
|
||||||
|
val first = s.firstEventAt ?: return 0L
|
||||||
|
val last = s.lastEventAt ?: return 0L
|
||||||
|
return (last - first).inWholeMilliseconds.coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokensPerSecond(completionTokens: Long, latencyMs: Long): Double =
|
||||||
|
if (latencyMs <= 0L) 0.0 else completionTokens.toDouble() / (latencyMs / MS_PER_SECOND)
|
||||||
|
|
||||||
|
private fun pct(part: Long, whole: Long): Double =
|
||||||
|
if (whole <= 0L) 0.0 else (part.toDouble() / whole.toDouble()) * PERCENT
|
||||||
|
|
||||||
|
private fun avg(total: Long, count: Long): Long =
|
||||||
|
if (count <= 0L) 0L else total / count
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.correx.apps.server.metrics
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
|
import com.correx.core.events.events.InferenceFailedEvent
|
||||||
|
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||||
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
|
import com.correx.core.sessions.projections.Projection
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure fold deriving [MetricsState] from a session's event log
|
||||||
|
* (observability-spec §2: observability-as-projection — no separate trace store, no OTel).
|
||||||
|
*
|
||||||
|
* Every metric here is replayable: a metric added later can be recomputed against events
|
||||||
|
* recorded before it existed. This projection records no new events and reads only events
|
||||||
|
* other bounded contexts already emit; it is a disposable read-model (Hard Invariant #2).
|
||||||
|
*/
|
||||||
|
class MetricsProjection : Projection<MetricsState> {
|
||||||
|
|
||||||
|
override fun initial(): MetricsState = MetricsState()
|
||||||
|
|
||||||
|
override fun apply(state: MetricsState, event: StoredEvent): MetricsState {
|
||||||
|
val at = event.metadata.timestamp
|
||||||
|
val spanned = state.copy(
|
||||||
|
firstEventAt = state.firstEventAt ?: at,
|
||||||
|
lastEventAt = at,
|
||||||
|
eventCount = state.eventCount + 1,
|
||||||
|
)
|
||||||
|
return when (val payload = event.payload) {
|
||||||
|
is InferenceCompletedEvent -> spanned.applyInferenceCompleted(payload)
|
||||||
|
is InferenceFailedEvent -> spanned.bumpFailure { it.copy(inferenceFailures = it.inferenceFailures + 1) }
|
||||||
|
is InferenceTimeoutEvent -> spanned.bumpFailure { it.copy(inferenceTimeouts = it.inferenceTimeouts + 1) }
|
||||||
|
is ToolExecutionCompletedEvent -> spanned.applyToolCompleted(payload)
|
||||||
|
is ToolExecutionFailedEvent -> spanned.bumpFailure { it.copy(toolFailures = it.toolFailures + 1) }
|
||||||
|
is ToolExecutionRejectedEvent -> spanned.bumpFailure { it.copy(toolRejections = it.toolRejections + 1) }
|
||||||
|
is ApprovalRequestedEvent -> spanned.applyApprovalRequested(payload, at)
|
||||||
|
is ApprovalDecisionResolvedEvent -> spanned.applyApprovalResolved(payload)
|
||||||
|
is StageFailedEvent -> spanned.bumpFailure { it.copy(stageFailures = it.stageFailures + 1) }
|
||||||
|
is WorkflowFailedEvent -> spanned.bumpFailure { it.copy(workflowFailures = it.workflowFailures + 1) }
|
||||||
|
else -> spanned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetricsState.bumpFailure(transform: (FailureMetrics) -> FailureMetrics): MetricsState =
|
||||||
|
copy(failures = transform(failures))
|
||||||
|
|
||||||
|
private fun MetricsState.applyInferenceCompleted(e: InferenceCompletedEvent): MetricsState {
|
||||||
|
val provider = e.providerId.value
|
||||||
|
val prev = inference.perProvider[provider] ?: ProviderInferenceMetrics()
|
||||||
|
val updated = prev.copy(
|
||||||
|
completedCount = prev.completedCount + 1,
|
||||||
|
totalLatencyMs = prev.totalLatencyMs + e.latencyMs,
|
||||||
|
promptTokens = prev.promptTokens + e.tokensUsed.promptTokens,
|
||||||
|
completionTokens = prev.completionTokens + e.tokensUsed.completionTokens,
|
||||||
|
)
|
||||||
|
return copy(
|
||||||
|
inference = inference.copy(
|
||||||
|
completedCount = inference.completedCount + 1,
|
||||||
|
totalLatencyMs = inference.totalLatencyMs + e.latencyMs,
|
||||||
|
promptTokens = inference.promptTokens + e.tokensUsed.promptTokens,
|
||||||
|
completionTokens = inference.completionTokens + e.tokensUsed.completionTokens,
|
||||||
|
perProvider = inference.perProvider + (provider to updated),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetricsState.applyToolCompleted(e: ToolExecutionCompletedEvent): MetricsState {
|
||||||
|
val durationMs = e.receipt.durationMs
|
||||||
|
val prev = tools.perTool[e.toolName] ?: ToolUsageMetrics()
|
||||||
|
val updated = prev.copy(
|
||||||
|
completedCount = prev.completedCount + 1,
|
||||||
|
totalDurationMs = prev.totalDurationMs + durationMs,
|
||||||
|
)
|
||||||
|
return copy(
|
||||||
|
tools = tools.copy(
|
||||||
|
completedCount = tools.completedCount + 1,
|
||||||
|
totalDurationMs = tools.totalDurationMs + durationMs,
|
||||||
|
perTool = tools.perTool + (e.toolName to updated),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetricsState.applyApprovalRequested(e: ApprovalRequestedEvent, at: Instant): MetricsState {
|
||||||
|
val tier = e.tier.name
|
||||||
|
val prev = approvals.perTier[tier] ?: TierApprovalMetrics()
|
||||||
|
return copy(
|
||||||
|
approvals = approvals.copy(
|
||||||
|
requestedCount = approvals.requestedCount + 1,
|
||||||
|
perTier = approvals.perTier + (tier to prev.copy(requestedCount = prev.requestedCount + 1)),
|
||||||
|
),
|
||||||
|
pendingApprovals = pendingApprovals + (e.requestId.value to at),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MetricsState.applyApprovalResolved(e: ApprovalDecisionResolvedEvent): MetricsState {
|
||||||
|
val requestedAt = pendingApprovals[e.requestId.value]
|
||||||
|
val waitMs = requestedAt
|
||||||
|
?.let { (e.resolutionTimestamp - it).inWholeMilliseconds.coerceAtLeast(0L) }
|
||||||
|
?: 0L
|
||||||
|
val tier = e.tier.name
|
||||||
|
val prev = approvals.perTier[tier] ?: TierApprovalMetrics()
|
||||||
|
return copy(
|
||||||
|
approvals = approvals.copy(
|
||||||
|
resolvedCount = approvals.resolvedCount + 1,
|
||||||
|
totalWaitMs = approvals.totalWaitMs + waitMs,
|
||||||
|
perTier = approvals.perTier + (
|
||||||
|
tier to prev.copy(
|
||||||
|
resolvedCount = prev.resolvedCount + 1,
|
||||||
|
totalWaitMs = prev.totalWaitMs + waitMs,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pendingApprovals = pendingApprovals - e.requestId.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.correx.apps.server.metrics
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model accumulated by [MetricsProjection] — a pure fold over a single session's event log.
|
||||||
|
*
|
||||||
|
* Holds raw sums only. Derived ratios (token throughput, average wait, time-accounting
|
||||||
|
* percentages) are computed at render time by [MetricsInspectionService] so this stays a
|
||||||
|
* stable, replayable accumulator. Never persisted — rebuilt from events on demand
|
||||||
|
* (Hard Invariant #1: the event log is the only source of truth).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MetricsState(
|
||||||
|
val firstEventAt: Instant? = null,
|
||||||
|
val lastEventAt: Instant? = null,
|
||||||
|
val eventCount: Long = 0,
|
||||||
|
val inference: InferenceMetrics = InferenceMetrics(),
|
||||||
|
val tools: ToolMetrics = ToolMetrics(),
|
||||||
|
val approvals: ApprovalMetrics = ApprovalMetrics(),
|
||||||
|
val failures: FailureMetrics = FailureMetrics(),
|
||||||
|
/**
|
||||||
|
* In-flight approval requests awaiting resolution: requestId → request-event timestamp.
|
||||||
|
* Transient correlation bookkeeping that lets request→decision latency be computed in a
|
||||||
|
* single forward fold. Resolved requests are removed; entries still present after the
|
||||||
|
* full replay are unresolved (their wait time is unknown and excluded from totals).
|
||||||
|
*/
|
||||||
|
val pendingApprovals: Map<String, Instant> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class InferenceMetrics(
|
||||||
|
val completedCount: Long = 0,
|
||||||
|
val totalLatencyMs: Long = 0,
|
||||||
|
val promptTokens: Long = 0,
|
||||||
|
val completionTokens: Long = 0,
|
||||||
|
val perProvider: Map<String, ProviderInferenceMetrics> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProviderInferenceMetrics(
|
||||||
|
val completedCount: Long = 0,
|
||||||
|
val totalLatencyMs: Long = 0,
|
||||||
|
val promptTokens: Long = 0,
|
||||||
|
val completionTokens: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolMetrics(
|
||||||
|
val completedCount: Long = 0,
|
||||||
|
val totalDurationMs: Long = 0,
|
||||||
|
val perTool: Map<String, ToolUsageMetrics> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolUsageMetrics(
|
||||||
|
val completedCount: Long = 0,
|
||||||
|
val totalDurationMs: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApprovalMetrics(
|
||||||
|
val requestedCount: Long = 0,
|
||||||
|
val resolvedCount: Long = 0,
|
||||||
|
val totalWaitMs: Long = 0,
|
||||||
|
val perTier: Map<String, TierApprovalMetrics> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TierApprovalMetrics(
|
||||||
|
val requestedCount: Long = 0,
|
||||||
|
val resolvedCount: Long = 0,
|
||||||
|
val totalWaitMs: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FailureMetrics(
|
||||||
|
val inferenceFailures: Long = 0,
|
||||||
|
val inferenceTimeouts: Long = 0,
|
||||||
|
val toolFailures: Long = 0,
|
||||||
|
val toolRejections: Long = 0,
|
||||||
|
val stageFailures: Long = 0,
|
||||||
|
val workflowFailures: Long = 0,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.correx.apps.server.routes
|
package com.correx.apps.server.routes
|
||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
|
import com.correx.apps.server.metrics.MetricsInspectionService
|
||||||
import com.correx.apps.server.protocol.SessionConfigDto
|
import com.correx.apps.server.protocol.SessionConfigDto
|
||||||
import com.correx.apps.server.replay.ReplayInspectionService
|
import com.correx.apps.server.replay.ReplayInspectionService
|
||||||
import com.correx.apps.server.serialization.payloadDiscriminator
|
import com.correx.apps.server.serialization.payloadDiscriminator
|
||||||
@@ -82,6 +83,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
|||||||
resumeSessionRoute(module)
|
resumeSessionRoute(module)
|
||||||
getEventsRoute(module)
|
getEventsRoute(module)
|
||||||
getReplayRoute(module)
|
getReplayRoute(module)
|
||||||
|
getMetricsRoute(module)
|
||||||
webSocket("/stream") {
|
webSocket("/stream") {
|
||||||
val id = call.parameters["id"] ?: return@webSocket
|
val id = call.parameters["id"] ?: return@webSocket
|
||||||
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
|
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
|
||||||
@@ -190,3 +192,13 @@ private fun Route.getReplayRoute(module: ServerModule) {
|
|||||||
call.respond(report)
|
call.respond(report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Route.getMetricsRoute(module: ServerModule) {
|
||||||
|
val service = MetricsInspectionService(module.eventStore)
|
||||||
|
get("/metrics") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
val report = service.inspect(TypeId(id))
|
||||||
|
call.respond(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package com.correx.apps.server.metrics
|
||||||
|
|
||||||
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
|
import com.correx.core.events.events.InferenceFailedEvent
|
||||||
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
|
import com.correx.core.events.events.ToolReceipt
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.inference.TokenUsage
|
||||||
|
import com.correx.core.utils.TypeId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MetricsProjectionTest {
|
||||||
|
|
||||||
|
private val sessionId: SessionId = TypeId("metrics-session-1")
|
||||||
|
private val stageId = TypeId("stage-1")
|
||||||
|
private val t0 = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
private fun at(seconds: Long): Instant = t0.plus(kotlin.time.Duration.parse("${seconds}s"))
|
||||||
|
|
||||||
|
private fun stored(payload: EventPayload, seq: Long, at: Instant): StoredEvent =
|
||||||
|
StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("e$seq"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = at,
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = seq,
|
||||||
|
sessionSequence = seq,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun inferenceDone(provider: String, latencyMs: Long, prompt: Int, completion: Int) =
|
||||||
|
InferenceCompletedEvent(
|
||||||
|
requestId = TypeId("inf-$provider-$latencyMs"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
providerId = TypeId(provider),
|
||||||
|
tokensUsed = TokenUsage(promptTokens = prompt, completionTokens = completion),
|
||||||
|
latencyMs = latencyMs,
|
||||||
|
responseArtifactId = TypeId("art-$provider-$latencyMs"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun toolDone(name: String, durationMs: Long) =
|
||||||
|
ToolExecutionCompletedEvent(
|
||||||
|
invocationId = TypeId("inv-$name-$durationMs"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
toolName = name,
|
||||||
|
receipt = ToolReceipt(
|
||||||
|
invocationId = TypeId("inv-$name-$durationMs"),
|
||||||
|
toolName = name,
|
||||||
|
exitCode = 0,
|
||||||
|
outputSummary = "ok",
|
||||||
|
durationMs = durationMs,
|
||||||
|
tier = Tier.T1,
|
||||||
|
timestamp = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val seed = listOf(
|
||||||
|
stored(inferenceDone("lfm:a", 2000, 100, 50), 1L, at(0)),
|
||||||
|
stored(inferenceDone("lfm:a", 3000, 200, 100), 2L, at(1)),
|
||||||
|
stored(inferenceDone("lfm:b", 1000, 10, 20), 3L, at(2)),
|
||||||
|
stored(toolDone("shell.read", 500), 4L, at(3)),
|
||||||
|
stored(toolDone("shell.read", 700), 5L, at(4)),
|
||||||
|
stored(toolDone("fs.write", 300), 6L, at(5)),
|
||||||
|
stored(
|
||||||
|
ApprovalRequestedEvent(
|
||||||
|
requestId = TypeId("req-1"),
|
||||||
|
tier = Tier.T2,
|
||||||
|
validationReportId = TypeId("vr-1"),
|
||||||
|
riskSummaryId = null,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
projectId = null,
|
||||||
|
),
|
||||||
|
7L, at(10),
|
||||||
|
),
|
||||||
|
stored(
|
||||||
|
ApprovalDecisionResolvedEvent(
|
||||||
|
decisionId = TypeId("dec-1"),
|
||||||
|
requestId = TypeId("req-1"),
|
||||||
|
outcome = ApprovalOutcome.APPROVED,
|
||||||
|
status = ApprovalStatus.COMPLETED,
|
||||||
|
tier = Tier.T2,
|
||||||
|
resolutionTimestamp = at(40),
|
||||||
|
reason = null,
|
||||||
|
),
|
||||||
|
8L, at(40),
|
||||||
|
),
|
||||||
|
stored(
|
||||||
|
ApprovalRequestedEvent(
|
||||||
|
requestId = TypeId("req-2"),
|
||||||
|
tier = Tier.T2,
|
||||||
|
validationReportId = TypeId("vr-2"),
|
||||||
|
riskSummaryId = null,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
projectId = null,
|
||||||
|
),
|
||||||
|
9L, at(50),
|
||||||
|
),
|
||||||
|
stored(
|
||||||
|
InferenceFailedEvent(
|
||||||
|
requestId = TypeId("inf-fail"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
providerId = TypeId("lfm:a"),
|
||||||
|
reason = "boom",
|
||||||
|
),
|
||||||
|
10L, at(51),
|
||||||
|
),
|
||||||
|
stored(
|
||||||
|
ToolExecutionFailedEvent(
|
||||||
|
invocationId = TypeId("inv-fail"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
toolName = "shell.read",
|
||||||
|
reason = "nonzero exit",
|
||||||
|
),
|
||||||
|
11L, at(52),
|
||||||
|
),
|
||||||
|
stored(
|
||||||
|
WorkflowFailedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
reason = "aborted",
|
||||||
|
retryExhausted = true,
|
||||||
|
),
|
||||||
|
12L, at(60),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
|
||||||
|
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||||
|
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||||
|
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||||
|
events.filter { it.metadata.sessionId == sessionId }
|
||||||
|
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||||
|
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
|
||||||
|
override fun lastSequence(sessionId: SessionId): Long? =
|
||||||
|
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
|
||||||
|
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||||
|
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||||
|
override suspend fun lastGlobalSequence(): Long = 0L
|
||||||
|
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
|
||||||
|
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report() = MetricsInspectionService(SeededEventStore(seed)).inspect(sessionId)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session span and event count come from event metadata`() {
|
||||||
|
val r = report()
|
||||||
|
assertEquals(sessionId.value, r.sessionId)
|
||||||
|
assertEquals(12L, r.eventCount)
|
||||||
|
assertEquals(60_000L, r.sessionDurationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inference totals and per-provider breakdown accumulate`() {
|
||||||
|
val r = report()
|
||||||
|
assertEquals(3L, r.inferenceCount)
|
||||||
|
assertEquals(6000L, r.inferenceMs)
|
||||||
|
assertEquals(310L, r.promptTokens)
|
||||||
|
assertEquals(170L, r.completionTokens)
|
||||||
|
|
||||||
|
val a = r.perProvider.single { it.provider == "lfm:a" }
|
||||||
|
assertEquals(2L, a.completedCount)
|
||||||
|
assertEquals(5000L, a.totalLatencyMs)
|
||||||
|
assertEquals(300L, a.promptTokens)
|
||||||
|
assertEquals(150L, a.completionTokens)
|
||||||
|
|
||||||
|
val b = r.perProvider.single { it.provider == "lfm:b" }
|
||||||
|
assertEquals(1L, b.completedCount)
|
||||||
|
assertEquals(1000L, b.totalLatencyMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tool totals and per-tool breakdown accumulate`() {
|
||||||
|
val r = report()
|
||||||
|
assertEquals(3L, r.toolCount)
|
||||||
|
assertEquals(1500L, r.toolMs)
|
||||||
|
|
||||||
|
val read = r.perTool.single { it.toolName == "shell.read" }
|
||||||
|
assertEquals(2L, read.completedCount)
|
||||||
|
assertEquals(1200L, read.totalDurationMs)
|
||||||
|
|
||||||
|
val write = r.perTool.single { it.toolName == "fs.write" }
|
||||||
|
assertEquals(1L, write.completedCount)
|
||||||
|
assertEquals(300L, write.totalDurationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `approval wait is correlated request-to-resolution and pending excludes wait`() {
|
||||||
|
val r = report()
|
||||||
|
assertEquals(2L, r.approvalsRequested)
|
||||||
|
assertEquals(1L, r.approvalsResolved)
|
||||||
|
assertEquals(1L, r.approvalsPending)
|
||||||
|
// req-1: requested at t+10s, resolved at t+40s -> 30s. req-2 unresolved -> contributes 0.
|
||||||
|
assertEquals(30_000L, r.approvalWaitMs)
|
||||||
|
assertEquals(30_000L, r.avgApprovalWaitMs)
|
||||||
|
|
||||||
|
val t2 = r.perTier.single { it.tier == "T2" }
|
||||||
|
assertEquals(2L, t2.requestedCount)
|
||||||
|
assertEquals(1L, t2.resolvedCount)
|
||||||
|
assertEquals(30_000L, t2.totalWaitMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failure counts are classified by event type`() {
|
||||||
|
val f = report().failures
|
||||||
|
assertEquals(1L, f.inferenceFailures)
|
||||||
|
assertEquals(1L, f.toolFailures)
|
||||||
|
assertEquals(1L, f.workflowFailures)
|
||||||
|
assertEquals(0L, f.inferenceTimeouts)
|
||||||
|
assertEquals(0L, f.toolRejections)
|
||||||
|
assertEquals(0L, f.stageFailures)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time accounting splits session wall time`() {
|
||||||
|
val r = report()
|
||||||
|
// inference 6000/60000, tools 1500/60000, approval-wait 30000/60000
|
||||||
|
assertEquals(10.0, r.inferencePct, 0.001)
|
||||||
|
assertEquals(2.5, r.toolPct, 0.001)
|
||||||
|
assertEquals(50.0, r.approvalWaitPct, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty session yields zeroed report`() {
|
||||||
|
val r = MetricsInspectionService(SeededEventStore(emptyList())).inspect(sessionId)
|
||||||
|
assertEquals(0L, r.eventCount)
|
||||||
|
assertEquals(0L, r.sessionDurationMs)
|
||||||
|
assertEquals(0L, r.inferenceCount)
|
||||||
|
assertEquals(0.0, r.tokensPerSecond, 0.001)
|
||||||
|
assertEquals(0.0, r.inferencePct, 0.001)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user