본문으로 건너뛰기
mcpd
데몬 없음
소스

Kotlin

코루틴이 먼저입니다. 활성 스팬은 코루틴 컨텍스트에 살고, 취소는 실패가 아니라 취소로 기록되며, 여기 있는 무엇도 에이전트에 예외를 던지지 않습니다.

설치

Kotlin 2.4로 빌드되며, JVM 17 이상에서 동작합니다. 의존성은 kotlinx의 coroutines와 serialization뿐입니다. HTTP는 JDK 자체 클라이언트를 쓰므로, HTTP에 대해 이미 나름의 견해를 가진 서비스에 아무것도 더하지 않습니다.

build.gradle.kts

dependencies {
    implementation("dev.mcpd:mcpd-kotlin:0.1.0")
}

클라이언트 만들기

프로세스마다 인스턴스 하나입니다. McpdConfig의 모든 필드는 환경 변수를 기본값으로 읽고 코드에서도 정할 수 있으므로, 같은 코드가 고치는 곳 없이 개발과 운영 양쪽에서 돌아갑니다. 인자가 없는 Mcpd()는 로컬 mcpd를 상대로 동작합니다.

연결

// Every field also reads an environment variable (MCPD_ENDPOINT,
// MCPD_PROJECT, MCPD_API_KEY, ...), so `Mcpd()` on its own works too.
val mcpd = Mcpd(
    McpdConfig(
        endpoint = "http://localhost:4317",
        project = "support-desk",
        environment = "development",
        serviceName = "support-agent",
        serviceVersion = "1.4.0",
    )
)

코드가 환경 변수를 이기고, 환경 변수가 기본값을 이깁니다.

필드환경 변수기본값
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEY없음
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENT없음
serviceNameMCPD_SERVICE_NAME없음
serviceVersionMCPD_SERVICE_VERSION없음
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMCPD_FLUSH_INTERVAL_MS2s
maxQueueMCPD_MAX_QUEUE8192
timeoutMCPD_TIMEOUT_MS10s

enabled = false는 모든 기록 호출을 아무 일도 하지 않는 호출로 만들면서도 올바른 스팬은 그대로 돌려줍니다. 그래서 시험 대상 코드를 건드리지 않고 테스트에서 mcpd를 끌 수 있습니다. 읽기는 그대로 서버로 갑니다. 쿼리는 호출자가 답을 기다리고 있는 것이고, 빈 답은 거짓말이 되기 때문입니다.

실행 기록하기

span이 관용적인 형태입니다. 스팬을 끝내고, 블록이 호출하는 모든 것에 대해 그 스팬을 활성 스팬으로 만들며, 블록이 돌려준 것을 그대로 돌려줍니다. 예외를 던지는 블록은 그 예외를 실은 error 스팬을 남기고, 예외는 바뀌지 않은 채로 다시 던집니다.

범위를 가진 형태

// The block's value is the value of span, so instrumentation does not force
// you to restructure the code around it.
val answer = mcpd.span("retrieve_context", SpanKind.RETRIEVAL) { span ->
    span.setInput(query)
    val docs = index.search(query)
    span.setOutput(docs.map { it.id })
    span.setAttribute("retrieval.hits", docs.size)
    docs
}

취소된 블록은 다르고, 다르게 기록됩니다. 호출자나 제한 시간에 의해 버려진 코루틴은 스팬을 error가 아니라 cancelled로 끝냅니다. 의도적으로 그만둔 것은 그 작업의 실패가 아니고, 트레이스 목록에서 실패처럼 읽혀서도 안 되기 때문입니다.

실행 기록하기

val question = "where is my order 41?"

// `span` starts the operation, ends it, and records anything it throws.
// Inside the block the span is the active one, so the helpers below find
// their parent through the coroutine context rather than being handed it.
val traceId = mcpd.span(
    "support_agent",
    SpanKind.AGENT_RUN,
    input = question,
    sessionId = "session-8f21",
    userId = "customer-4477",
    tags = listOf("support", "email"),
) { run ->
    mcpd.log(LogLevel.INFO, "handling a question", mapOf("channel" to "email"))

    val plan = planAnswer(mcpd, question)
    val order = lookUpOrder(mcpd, 41)
    val estimate = estimateDelivery(mcpd, 41)

    val answer = buildString {
        append(plan).append(' ').append(order)
        if (estimate != null) append(" It should arrive ").append(estimate).append('.')
    }
    run.setOutput(answer)
    mcpd.metric("support.answers", 1.0, MetricKind.COUNTER, unit = "{answer}")

    // The block's value is the value of `span`.
    run.traceId
}

블록이 맞는 모양이 아닐 때는 스팬을 직접 시작하고 끝내십시오.

블록 없이

val run = mcpd.trace("nightly_reindex", SpanKind.WORKFLOW_RUN, tags = listOf("batch"))
try {
    reindex()
    run.end()
} catch (e: Exception) {
    run.setError(e)
    run.end()
}

컨텍스트

활성 스팬은 코루틴 컨텍스트의 한 요소이므로, 스레드가 아니라 코루틴을 따라갑니다. 몇 겹 아래에 있는 suspend 함수도 호출자가 부모를 넘겨 주지 않은 채 올바른 부모의 자식을 열고, 같은 디스패처 위의 두 실행은 상대의 스팬을 보지 못합니다. mcpd.currentSpan()으로 읽고, mcpd.traceparent()로 그 헤더를 얻습니다.

중첩

mcpd.span("support_agent", SpanKind.AGENT_RUN) { run ->
    // draft is handed nothing. It finds the run through the coroutine context,
    // however many suspend calls down it is.
    run.setOutput(draft(question))
}

suspend fun draft(question: String): String =
    mcpd.span("draft_answer", SpanKind.MODEL_GENERATION) { generation ->
        generation.setInput(question)
        "..."
    }

서비스 사이에서 컨텍스트는 W3C traceparent 헤더에 실려 갑니다. 이 헤더가 샘플링 결정을 함께 나르므로, 트레이스가 절반만 기록되는 일이 없습니다. 형식이 깨진 헤더는 그것을 실어 온 요청을 실패시키는 대신 새 트레이스를 시작합니다.

서비스 경계를 넘어

// Outgoing: hand the header to the next service.
request.header("traceparent", span.traceparent())

// Incoming: continue that trace instead of starting a new one.
mcpd.span("checkout", traceparent = headers["traceparent"]) { span ->
    charge(span)
}

모델, 사용량, 오류

mcpd가 수집 시점에 비용으로 바꾸는 대상이 토큰 수이므로, 트레이스에 적힌 숫자가 곧 청구받은 숫자입니다. 오류를 기록하면 같은 동작으로 스팬의 상태도 error가 되고, 메시지는 언제나 붙어 있습니다. ‘무언가 실패했다’로는 손쓸 수 있는 것이 없습니다.

스팬이 싣는 것

generation.setModel("gpt-4o", provider = "openai")
generation.setUsage(inputTokens = 1_180, outputTokens = 96)

// setError takes the throwable you are already holding, so the message, the
// class and the stack all come off it.
tool.setError(e, retryable = true)

// Or a message directly, which is required either way.
tool.setError("the shipping service returned 503")

나머지는 setInput, setOutput, setMetadata, setAttribute, setAttributes, setTags, setCorrelation, addLink이고, 명시적인 자식을 만드는 child와 한 시점을 남기는 event가 더 있습니다. 모두 어느 스레드에서 호출해도 안전하고, 어느 것도 예외를 던지지 않습니다. 다른 트레이서의 트리에 속한 스팬은 externalParentSpanId로 그것을 가리키며, 이 값은 상관 정보로 실려 갈 뿐 mcpd 자신의 트리 안에서 부모로 쓰이지 않습니다.

페이로드, 싱크, 끝난 스팬

capturePayloads = false는 input과 output을 프로세스 안에 붙들어 둡니다. 레코드가 큐에 닿기 전에 버려지므로 민감한 것이 플러시를 타고 나가지 않고, 스팬 하나가 자기 몫만 달리 정할 수 있습니다. 익스포터는 모든 배치를 Sink에 넘깁니다. 기본값은 HTTP 전송 계층이고, 테스트를 위해 배치를 메모리에 들고 있는 RecordingSink와 시그널마다 JSON 한 줄을 쓰는 ConsoleSink가 있습니다. 예외를 던지는 싱크는 실패한 배치이며, 한 주기에 한 번 보고될 뿐 호출자에게 올라오지 않습니다.

담기, 기록하기, 검증하기

// Keep prompts and completions in the process: the span still carries its
// timing, status, usage, model, correlation and errors.
val quiet = Mcpd(McpdConfig(capturePayloads = false))
quiet.span("draft_answer", input = question, capturePayloads = true) { }  // this one, kept

// A span another tracer already finished, queued as given: never re-parented,
// re-timed or re-sampled. A malformed id is dropped and counted, never thrown.
mcpd.record(finishedSpan)

// Prove what was recorded without a server.
val recorder = RecordingSink()
val local = Mcpd(McpdConfig(sink = recorder))
local.span("support_agent") { run -> run.setUsage(inputTokens = 12, outputTokens = 3) }
local.flush()
check(recorder.spans.single().usage?.inputTokens == 12L)

// queued, dropped, capacity, exported, failed, rejected.
val stats = local.stats()

지속성 실행

같은 클라이언트가 데몬의 지속성 실행도 다룹니다. Workflow는 정의를 만들고 단계 본문을 suspend 람다로 들고 있습니다. Worker는 그 정의를 등록하고, 리스를 롱 폴링으로 기다렸다가 코루틴 위에서 하나씩 처리합니다. startRun, watchRun, publishEvent와 나머지는 실행 표면을 하나씩 그대로 옮겨 놓은 클라이언트 확장 함수입니다. 본문은 단계의 예산 안에서 다시 시도되도록 RetryableException을 던지고, StepContext를 통해 자기 단계 아래에 스팬을 기록합니다. 컨텍스트를 통해 연 모든 스팬은 끝나면서 자기 사용량을 그 단계의 사용량에 더합니다. 그래서 모델 호출을 기록하는 본문은 토큰을 손으로 합산하지 않습니다. setUsage는 스팬들이 합산한 값을 덮어씁니다. Worker는 onError 옆에 선택적인 훅 넷을 더 받고 (onLease, onStepStart, onStepEnd, onConflict), stats()로 확보한 리스, 진행 중인 본문, 완료, 분류별 실패, 사유별 충돌, 처리할 수 없는 리스를 답합니다.

워크플로 하나

val support = Workflow("support_ticket")
    .step("classify") { ctx ->
        val ticket = ctx.input<JsonObject>()["ticket"]!!.jsonPrimitive.content
        mapOf("kind" to if (ticket.startsWith("Q")) "question" else "bug")
    }
    .agent("draft", StepOptions(input = mapOf("kind" to "\${steps.classify.output.kind}"))) {
        ctx ->
        // Your own model call, recorded under this step in the run's trace.
        // Its usage is summed into the step, so the daemon prices the run.
        ctx.span("model_call", SpanKind.MODEL_GENERATION) { span ->
            span.setModel("claude-sonnet-4", "anthropic")
            span.setUsage(inputTokens = 812, outputTokens = 37)
        }
        "an answer to a " + ctx.input<JsonObject>()["kind"]!!.jsonPrimitive.content
    }
    .waitFor(
        "approval", "approved", "\${input.ticket}", 7.days,
        StepOptions(guard = predicate("steps.classify.output.kind", "eq", "bug")),
    )
    .step(
        "send",
        StepOptions(
            input = mapOf("text" to "\${steps.draft.output}"),
            effect = Effect.AT_MOST_ONCE,
            retry = RetryPolicy(
                maxAttempts = 3, backoff = Backoff.EXPONENTIAL, initial = 500.milliseconds,
            ),
        ),
    ) { ctx ->
        val text = ctx.input<JsonObject>()["text"]!!.jsonPrimitive.content
        if (!deliver(text, ctx.idempotencyKey)) {
            throw RetryableException("mail relay busy", retryAfter = 2.seconds)
        }
        mapOf("sent" to true)
    }
    .output(mapOf("sent" to "\${steps.send.output.sent}"))

각 구성 요소가 무엇을 하는지, 리스 프로토콜, 실행 API는 워크플로, 워커, 실행에 있습니다.

점수와 피드백

min과 max는 필수 매개변수입니다. 7은 10점 만점에서는 훌륭하고 100점 만점에서는 형편없으므로, 척도를 밝히지 않은 숫자는 무엇과도 비교할 수 없습니다. 피드백은 사람의 판단이고 점수와 따로 둡니다. 둘은 서로 다른 질문에 답하는 것이라 함께 평균 내서는 안 되기 때문입니다.

점수와 피드백

// A machine evaluation. `min` and `max` are required: 7 is excellent out of
// 10 and poor out of 100, and a score with no scale cannot be compared.
mcpd.score(
    traceId = traceId,
    scorerId = "answer_relevance",
    value = 0.86,
    min = 0.0,
    max = 1.0,
    reason = "answered the question asked, without the delivery estimate",
)

// A person's judgement, from the thumbs-up control in your own product.
mcpd.feedback(
    traceId = traceId,
    kind = FeedbackKind.THUMBS,
    value = "up",
    comment = "fast and clear",
    authorId = "customer-4477",
)

// Nothing above touched the network. This is where it goes.
mcpd.flush()

로그와 메트릭은 활성 스팬에 붙습니다. 그래서 차트 위의 치솟은 봉우리에서 그것을 일으킨 실행을 열 수 있습니다.

플러시와 종료

기록은 상한이 있는 메모리 큐에 덧붙이고 바로 돌아옵니다. 배치로 묶고, 압축하고, 보내는 일은 워커 코루틴이 합니다. flush()는 큐에 있는 것을 보내고 기다립니다. shutdown()은 워커를 멈추고 남은 것을 보내며, 두 번 호출해도 안전합니다. 마지막 내보내기는 취소되지 않게 돌아갑니다. 종료는 대개 취소 도중의 finally에서 도달하는데, 프로세스가 왜 사라지는지 설명해 주는 것이 바로 그 마지막 배치이기 때문입니다.

Mcpd는 AutoCloseable이므로 use도 종료 훅도 됩니다. 둘 다 부르지 않는 서버리스 함수나 짧게 사는 워커는 마지막 배치를 잃습니다. 런타임이 핸들러가 돌아오는 순간 스케줄링을 멈추기 때문입니다.

종료하기

// A JVM service: shut down on the way out.
Runtime.getRuntime().addShutdownHook(Thread { mcpd.close() })

// A short-lived worker: use, and it shuts down at the closing brace.
Mcpd(McpdConfig(project = "batch")).use { mcpd ->
    mcpd.span("reindex") { run -> reindex(run) }
}

돌려받는 것

읽기 메서드는 HTTP API를 하나씩 그대로 옮긴 것이고 생성된 타입을 돌려줍니다. 예외를 던지는 메서드는 이것들뿐이고, 던지는 것은 McpdException입니다. 답을 기다리고 있는 쿼리는 삼키고 넘어갈 것이 아니기 때문입니다.

실행을 다시 읽기
$ gradle run> Task :runsupport_agent: 4 spans, ok  agent_run support_agent 79ms  model_generation plan_answer 41ms  tool_call lookup_order 16ms  tool_call estimate_delivery 11ms shipping service returned 503 for order 41

검색과 분석

val detail = mcpd.getTrace(traceId, include = listOf("scores", "feedback"))
println("${detail.trace.name}: ${detail.spans.size} spans, ${detail.trace.status}")
for (span in detail.spans) {
    val ms = (span.durationNs ?: 0) / 1_000_000
    println("  ${span.kind} ${span.name} ${ms}ms ${span.error?.message ?: ""}")
}

val summary = mcpd.analyticsSummary(
    from = Clock.System.now() - 1.hours,
    to = Clock.System.now(),
)
println(
    "last hour: ${summary.traceCount} runs, " +
        "${summary.errorCount} failed, ${summary.totalCost} USD",
)

믿어도 되는 것

  • 계측은 절대 예외를 던지지 않습니다. 실패한 내보내기, 닿지 않는 서버, 직렬화되지 않는 값은 java.util.logging을 통해 30초에 많아야 한 번 보고되고 그 밖에는 삼킵니다. 관측 가능성이 관측 대상을 무너뜨리는 일은 절대 없어야 합니다.
  • 기록은 블로킹하지 않으므로, 서비스의 지연 시간이 mcpd가 빠른지, 살아 있는지에 달리는 일이 없습니다.
  • 메모리에는 상한이 있습니다. 큐는 maxQueue개의 시그널을 담고, 그 너머로는 가장 오래된 것부터 버리고 셉니다. 그 수는 droppedSignals와 stats()에서 읽을 수 있습니다. 상한 없는 버퍼는 네트워크 장애를 메모리 부족으로 인한 강제 종료로 바꿔 놓습니다.
  • 샘플링 결정은 루트에서 한 번 내리고 아래로 물려받습니다.
  • 스팬은 시작할 때 한 번, 끝날 때 다시 한 번 내보냅니다. 그래서 도중에 죽은 실행도 그대로 보입니다. 시작 레코드가 나가기 전에 스팬이 끝나면 그 레코드를 제자리에서 갱신하고 하나만 보냅니다.
  • 배치는 1 KiB를 넘으면 gzip으로 압축하고, 재시도를 넘어 살아남는 멱등성 키를 싣습니다. 그래서 서버가 저장한 뒤에 제한 시간이 지난 요청이 두 번 세어지는 일이 없습니다.
  • 지속 시간은 단조 시계로 잽니다. 그래서 NTP가 시각을 옮겨도 음수가 나올 수 없습니다.

실행 찾기가 필터 전체를 다룹니다. 데이터 모델은 이 타입들이 싣는 모든 필드를 나열합니다.