Skip to content

Instantly share code, notes, and snippets.

@yuk1ty
Last active June 25, 2026 15:01
Show Gist options
  • Select an option

  • Save yuk1ty/63bb1c94701d1a1f34d04bc187cb59d5 to your computer and use it in GitHub Desktop.

Select an option

Save yuk1ty/63bb1c94701d1a1f34d04bc187cb59d5 to your computer and use it in GitHub Desktop.
Opus 4.8とGLM 5.2の調査ファイル比較

OpenTelemetry Integration Research

Summary

Kamellia currently has zero observability infrastructure (a grep for opentelemetry|otel|tracing|span|tracer|meter|traceparent|traceId across the whole repo returns nothing in source). Fortunately the framework's core abstraction — Middleware = (RawHandler) -> RawHandler, the onion/wrapper pattern — is exactly the shape OpenTelemetry needs for server-side instrumentation, and LoggingMiddleware is a near-perfect template to clone into a tracing middleware. The one real design hurdle is the http.route semantic convention: the matched route pattern (/users/:id) is currently discarded by Router.match() before middleware runs, so middleware can only see the concrete path. Everything else (W3C context extraction, span lifecycle, error recording, status mapping) drops cleanly into the existing middleware + Context model with no changes to core types.

Architecture Overview

Request lifecycle today (from ARCHITECTURE.md and KamelliaHandler.channelRead0):

Netty Channel
    │
    ▼
HttpServerCodec + HttpObjectAggregator        (NettyServer.kt:44-48)
    │
    ▼
KamelliaHandler.channelRead0(ctx, msg)        (KamelliaHandler.kt:50)
    │
    ├── RequestConverter.convert(msg) → Request   (RequestConverter.kt:13)
    │       headers are lowercased here (RequestConverter.kt:18) ← key for traceparent
    │
    ├── router.match(request) → RouteMatch?       (Router.kt:13)
    │       ⚠ returns only (pathParams, handler) — pattern is LOST (RouteMatch.kt:5)
    │
    ├── composeMiddlewares(middlewares, handler)  (MiddlewareComposer.kt:5)
    │       foldRight: A(B(C(handler)))            ← OTel middleware plugs in here
    │
    ├── handler(requestWithParams) → Response     (KamelliaHandler.kt:77)
    │       catch (e: Exception) → errorHandler   (KamelliaHandler.kt:78)
    │
    ├── ResponseConverter.convertFull(response)   (ResponseConverter.kt:19)
    │
    ▼
Netty writeAndFlush → Client

Where OpenTelemetry fits:

                    ┌─────────────────────────────────────────┐
                    │  tracingMiddleware (NEW)                 │
                    │   1. Extract W3C TraceContext from       │
                    │      req.headers["traceparent"]          │
                    │   2. span = tracer.spanBuilder(...)      │
                    │         .setParent(extracted)            │
                    │         .startSpan()                     │
                    │   3. req.context.set("otel.span", span)  │
                    │   4. try { next(req) }                   │
                    │        catch (e: Exception) {            │
                    │           span.recordException(e);       │
                    │           span.status = ERROR; throw e   │
                    │        } finally { span.end() }          │
                    │   5. set http.response.status_code attr  │
                    └─────────────────────────────────────────┘
                          │  (wraps, via composeMiddlewares)
                          ▼
                     user handler / next middleware

The middleware is registered with app.use(tracingMiddleware(...)) exactly like the existing loggingMiddleware() / corsMiddleware() usage in examples/middleware/MiddlewareExample.kt:29-38.

Key Files

File Purpose
kamellia-middleware/src/main/kotlin/io/github/kamellia/middleware/Middleware.kt:5 typealias Middleware = (RawHandler) -> RawHandler — the contract a tracing middleware implements
kamellia-middleware/src/main/kotlin/io/github/kamellia/middleware/MiddlewareComposer.kt:5 composeMiddlewaresfoldRight onion composition; tracing middleware should be registered first so it becomes the outermost layer
kamellia-middleware/src/main/kotlin/io/github/kamellia/middleware/LoggingMiddleware.kt:14 Primary template to clone. Generates request ID, measures time via measureTimedValue, wraps next(req), logs request/response. A span is the observability analogue of every log line here.
kamellia-core/src/main/kotlin/io/github/kamellia/core/Context.kt:4 value class Context — mutable request-scoped kv store. This is where the active Span should be stashed so downstream middlewares/handlers can retrieve it (mirrors requestIdMiddleware in examples/middleware/MiddlewareExample.kt:18-24).
kamellia-core/src/main/kotlin/io/github/kamellia/core/Request.kt:5 Request data class — carries method, path, headers (lowercased Map<String,String>), pathParams, queryParams, body, context. All span attribute sources.
kamellia-core/src/main/kotlin/io/github/kamellia/core/Response.kt:22 Response(status, headers, body)status: HttpStatus feeds http.response.status_code.
kamellia-core/src/main/kotlin/io/github/kamellia/core/HttpStatus.kt:3 HttpStatus enum with .code: Int — needed for span status mapping (≥500 → ERROR, ≥400 → ERROR per OTel convention is debated; OTel spec says set ERROR only for ≥5xx or unhandled).
kamellia-netty/src/main/kotlin/io/github/kamellia/netty/RequestConverter.kt:18 Lowercases header keys (entry.key.lowercase()). Important: W3C traceparent header must be looked up as "traceparent" (lowercase), which is already guaranteed here.
kamellia-netty/src/main/kotlin/io/github/kamellia/netty/KamelliaHandler.kt:50 The Netty entry point. Launches one coroutine per request (CoroutineScope(dispatcher).launch, line 55). Handler exceptions caught at line 78 and routed to errorHandler.
kamellia-router/src/main/kotlin/io/github/kamellia/routing/Router.kt:13 Router.match() returns RouteMatch?does not expose the matched Route.pattern. This is the root cause of the http.route gotcha.
kamellia-router/src/main/kotlin/io/github/kamellia/routing/RouteMatch.kt:5 data class RouteMatch(pathParams, handler) — would need a pattern/route field to support http.route.
kamellia-router/src/main/kotlin/io/github/kamellia/routing/Route.kt:7 Route.method, Route.pattern — the pattern is known at match time, just not propagated.
kamellia-core/src/main/kotlin/io/github/kamellia/core/ErrorHandler.kt:3 typealias ErrorHandler = (Exception, Request?) -> Response. Tracing middleware should rethrow so the existing error handler still runs; OR the framework could augment ErrorHandler to receive span info (not recommended for v1).
kamellia-core/src/main/kotlin/io/github/kamellia/error/HttpException.kt:5 HttpException(status: HttpStatus, ...) — carries an HttpStatus. Useful for mapping span.status from a thrown HttpException even when the error handler converts it to a 4xx response (4xx is typically NOT span-ERROR per OTel).
kamellia-app/src/main/kotlin/io/github/kamellia/Kamellia.kt:16 Kamellia.use(middleware) — the user-facing registration API.
settings.gradle.kts:7 Module list — a new kamellia-opentelemetry module gets added here.
build.gradle.kts:26 Root subprojects block — shared deps (kotlin-test, coroutines-test) and detekt/spotless config that the new module inherits.
kamellia-kotlin-result/build.gradle.kts Template for the new module's build file. Integration-module pattern: api(project(":kamellia-core")) + a third-party api(...) dependency.
config/detekt/detekt.yml Rules the new module must satisfy (see Gotchas).

Patterns & Conventions

Module layout (follow kamellia-kotlin-result / kamellia-arrow-kt)

Integration modules are thin: one api(project(":kamellia-core")), one external api("...") dependency, a few extension files. A kamellia-opentelemetry module should:

  • api(project(":kamellia-core"))
  • api(project(":kamellia-middleware")) (for the Middleware typealias and RawHandler)
  • api("io.opentelemetry:opentelemetry-api:<version>")API only, not the SDK. This is the canonical OTel library-instrumentation pattern: a library depends on the API; the application brings the SDK + exporter. This keeps Kamellia dependency-light and lets users choose their own exporter (OTLP, Jaeger, Zipkin, console).
  • testImplementation("io.opentelemetry:opentelemetry-sdk-testing:<version>") — for InMemorySpanExporter in tests.

Middleware authoring conventions (from LoggingMiddleware.kt + CorsMiddleware.kt)

  • Top-level fun nameMiddleware(config: NameConfig = NameConfig()): Middleware = { next -> { req -> ... } }. Configuration is a data class with defaults (LoggingConfig, CorsConfig).
  • Side-effect logger/dependencies are file-private vals (private val logger = KotlinLogging.logger {}).
  • Constants are private const val at file top (REQUEST_ID_LENGTH).
  • The middleware returns the response (possibly copy(...)-ed for header decoration); it does not synthesize responses unless short-circuiting (CORS does for OPTIONS).
  • Timing uses kotlin.time.measureTimedValue { next(req) } — reuse the same for span duration, or just rely on span.end() which timestamps automatically.

Error-handling convention (and the AGENTS.md hard rule)

  • AGENTS.md: "You MUST NOT catch Throwable exceptions. DON'T use runCatching." So the tracing middleware must use try { ... } catch (e: Exception) { ... } and try/finally for span.end(). This aligns with KamelliaHandler.kt:78 which already catches Exception (with @Suppress("TooGenericExceptionCaught")).
  • Pattern to record errors and rethrow (so the existing ErrorHandler in KamelliaHandler still runs):
    val span = ...
    try {
        val response = next(req)
        span.setAttribute(HTTP_RESPONSE_STATUS_CODE, response.status.code)
        span.status = if (response.status.code >= 500) StatusCode.ERROR else StatusCode.UNSET
        response
    } catch (e: Exception) {
        span.recordException(e)
        span.status = StatusCode.ERROR
        throw e
    } finally {
        span.end()
    }
    detekt.yml:138 has TooGenericExceptionCaught: active: false, and SwallowedException: active: true — rethrowing means it's not swallowed, so this is safe.

Naming / formatting

  • 4-space indent, 120 max line length, ktlint 1.8.0 (build.gradle.kts:38-44).
  • No wildcard imports (ktlint_standard_no-wildcard-imports is disabled, but detekt WildcardImport: active: true at detekt.yml:483 — so write explicit imports).
  • MagicNumber: active: true (excludes tests) at detekt.yml:334 — HTTP status thresholds (500, 400) and OTel span attribute values that are numeric literals need private const val declarations.
  • ReturnCount: max: 2, ThrowsCount: max: 2 — keep the middleware body linear.
  • Use value class / data class / object per semantic intent (AGENTS.md); don't use runCatching.

Span attribute source mapping (OTel HTTP semantic conventions, stable)

OTel attribute Source in Kamellia
http.request.method req.method.name (HttpMethod enum, HttpMethod.kt:3)
url.path req.path
url.query reconstruct from req.queryParams (no raw query string stored — see Gotchas)
http.route Route.pattern — NOT currently available to middleware (see Gotchas)
http.response.status_code response.status.code (HttpStatus.kt)
error.type exception class name, set when recording
traceparent / tracestate req.headers["traceparent"] / req.headers["tracestate"] (lowercased by RequestConverter.kt:18)

Similar Implementations

1. LoggingMiddleware (direct clone template)

kamellia-middleware/src/main/kotlin/io/github/kamellia/middleware/LoggingMiddleware.kt:14 — same shape: wrap next, generate correlation id, time the call, observe response. A tracingMiddleware is literally this but creating a Span instead of log lines and storing the span in req.context instead of withLoggingContext.

2. requestIdMiddleware (Context-stash template)

examples/middleware/MiddlewareExample.kt:18-24:

private val requestIdMiddleware: Middleware = { next ->
    { req ->
        val requestId = UUID.randomUUID().toString().take(REQUEST_ID_LENGTH)
        req.context.set(REQUEST_ID_CONTEXT_KEY, requestId)
        next(req)
    }
}

This is exactly the pattern for exposing the active span to user handlers: req.context.set("otel.span", span) and downstream code does req.context.get<Span>("otel.span"). Reuse this — do not invent a new mechanism.

3. errorHandlingMiddleware test (exception catch/rethrow pattern)

kamellia-middleware/src/test/kotlin/io/github/kamellia/middleware/MiddlewareIntegrationTest.kt:160-182 proves a middleware can try { next(request) } catch (e: ...) { ... } and either convert to a response or rethrow. The tracing middleware rethrows so KamelliaHandler's ErrorHandler keeps working.

4. Integration-module precedent (kamellia-kotlin-result, kamellia-arrow-kt)

kamellia-kotlin-result/build.gradle.kts is the cleanest template for kamellia-opentelemetry/build.gradle.kts: api(project(":kamellia-core")) + api(<third-party lib>), then IntoResponse-style adapter files. The OTel module's "adapter" is the tracingMiddleware function plus a small Request extension to retrieve the active span from Context.

Gotchas & Constraints

1. http.route is not available to middleware (biggest design decision)

Router.match() (Router.kt:13-24) returns RouteMatch(pathParams, handler) (RouteMatch.kt:5) — the matched Route.pattern (Route.kt:7) is discarded. Middleware only receives Request (concrete path like /users/42), never the pattern (/users/:id). OTel's stable HTTP semantic convention marks http.route as "Conditionally Required: If and only if one has access to the route pattern."

Three options, in order of increasing invasiveness:

  • (A) Set http.route later, in KamelliaHandler. Do tracing partly in the Netty layer instead of pure middleware. KamelliaHandler.channelRead0 already has routeMatch in scope (KamelliaHandler.kt:59); it could stash routeMatch's pattern into request.context before invoking the composed middleware. Requires exposing pattern on RouteMatch (add val pattern: String to RouteMatch.kt:5 and populate it in Router.kt:19). The tracing middleware then reads req.context.get<String>("http.route") and sets the attribute if present. Recommended — minimal, backward-compatible, keeps the span lifecycle in middleware where it belongs.
  • (B) Pure middleware, omit http.route. Simplest, but produces low-cardinality-poor spans (every distinct URL is a distinct span signature). Acceptable for v1 / MVP.
  • (C) Refactor middleware to receive the match. Change Middleware to take route info. Breaks the existing typealias and every middleware. Not recommended.

Option (A) requires touching RouteMatch + Router + KamelliaHandler — small, additive changes — and is worth doing.

2. url.query is not stored as a raw string

RequestConverter.kt:14 parses the URI via QueryStringDecoder and stores queryParams: QueryParams (typed accessor) but not the original query string. To populate url.query, either reconstruct from queryParams (lossy for repeated keys/ordering) or add a val query: String to Request populated in RequestConverter from decoder.path()-stripped URI. Low priority for v1 (OTel allows omitting it).

3. Coroutine / OTel Context propagation

KamelliaHandler.kt:55 launches one coroutine per request on the Netty event-loop dispatcher. OpenTelemetry's Context is attached to the current thread/coroutine. Within the single request coroutine the span scope (tracer.withSpan(span) { next(req) } or Span.current()) works. But: if a user handler spawns child coroutines (launch { ... } inside a handler), the OTel Context will NOT automatically propagate unless the user wires in opentelemetry-kotlin coroutine instrumentation or stores/re-applies the Context manually. The Kamellia-idiomatic workaround is what requestIdMiddleware already does: store the span in req.context (Context.kt:4) so any code with the Request can reach it regardless of coroutine boundary. Document this trade-off; don't try to solve coroutine Context propagation inside the framework.

4. Netty pipeline is not where tracing should live

NettyServer.kt:44-48 adds HttpServerCodec + HttpObjectAggregator + KamelliaHandler. One could add a Netty ChannelInboundHandlerAdapter for tracing, but that bypasses Kamellia's Middleware/Context model and can't see the matched route or Response status easily. Keep tracing in middleware; the Netty layer's only role is the Option (A) http.route stash above.

5. detekt MagicNumber for status thresholds

if (status.code >= 500) triggers MagicNumber in non-test code (detekt.yml:334). Extract private const val HTTP_SERVER_ERROR_THRESHOLD = 500. Same for any numeric OTel attribute values.

6. SwallowedException + AGENTS "no Throwable" rule

Do NOT swallow exceptions to keep the span "clean" — that would break KamelliaHandler's ErrorHandler contract. Always rethrow after span.recordException(e). And per AGENTS.md, catch Exception, never Throwable, and never runCatching.

7. Library-instrumentation dependency discipline

Resist the temptation to api("io.opentelemetry:opentelemetry-sdk") in kamellia-opentelemetry. Libraries depend on opentelemetry-api only; the application provides the SDK and exporter. This is universal OTel best practice and keeps Kamellia's transitive footprint small. Tests can use opentelemetry-sdk-testing (InMemorySpanExporter).

8. Backward compatibility

RouteMatch is a data class used in tests (MiddlewareIntegrationTest.kt:61, etc.). Adding a pattern field is a source-incompatible change for direct constructors but the AGENTS.md says backward compatibility is not always required ("You DON'T always need to think the backward compatibility"). The project is 0.1.0-SNAPSHOT with no external users. Adding the field is fine; update Router.match() and any tests that construct RouteMatch directly.

9. Streaming responses

ResponseConverter.writeStreamed (ResponseConverter.kt:44) is the path for Body.Streamed. A pure middleware sees the Response object before it's written to the wire, so response.status is available. The span will end before the stream is fully flushed (since next(req) returns once the Response object is built, not after bytes are sent). This is the same limitation LoggingMiddleware has today and is acceptable; if true send-completion timing is needed, that must live in KamelliaHandler.sendResponse (KamelliaHandler.kt:89), not middleware.

Relevant Tests

Existing tests to mirror (and not break)

File Pattern to copy
kamellia-middleware/src/test/kotlin/io/github/kamellia/middleware/LoggingMiddlewareTest.kt The template for TracingMiddlewareTest. Uses Logback ListAppender to capture logs — the OTel equivalent is InMemorySpanExporter to capture spans. Builds Request manually with QueryParams.empty() / PathParams.empty() / Context(), wraps a stub RawHandler, asserts on the captured artifacts. Note @BeforeTest/@AfterTest setup-teardown pattern.
kamellia-middleware/src/test/kotlin/io/github/kamellia/middleware/MiddlewareIntegrationTest.kt:160 errorHandlingMiddleware test — proves catch-in-middleware works. Write an analogous test that asserts span.recordException + span.status == ERROR + the exception propagates.
kamellia-middleware/src/test/kotlin/io/github/kamellia/middleware/MiddlewareComposerTest.kt Onion ordering assertions (m1-pre, m2-pre, handler, m2-post, m1-post). Add a test that tracingMiddleware registered first is outermost (sees the final Response status and any exception).
kamellia-netty/src/test/kotlin/io/github/kamellia/netty/DefaultErrorHandlerTest.kt Confirms HttpException → its status, generic → 500. The tracing middleware's status mapping must agree with this (e.g. a 404 HttpException thrown from a handler → response.status = 404 → span.status should be UNSET, not ERROR, per OTel).
kamellia-core/src/test/kotlin/io/github/kamellia/core/ContextTest.kt Context get/set/remove/clear semantics — relevant for the req.context.set("otel.span", span) approach.

Test framework conventions

  • kotlin.test (kotlin.test.Test, assertEquals, assertTrue), JUnit Platform (useJUnitPlatform() in build.gradle.kts:33).
  • Coroutines: kotlinx.coroutines.test.runTest (NOT runBlocking) — see every middleware test.
  • No HTTP server is spun up in tests; the router + middleware composition is exercised directly. To test W3C traceparent extraction, construct Request(headers = mapOf("traceparent" to "00-<trace>-<span>-01")) and assert the resulting span's parent SpanContext matches. No Netty boot required.
  • Add to kamellia-opentelemetry/src/test/kotlin/...:
    • TracingMiddlewareTest — span creation, attributes (http.request.method, url.path, http.response.status_code), status mapping (2xx→UNSET, 5xx→ERROR, thrown exception→ERROR+recordException).
    • TraceContextExtractionTesttraceparent header → parent span linkage.
    • ActiveSpanAccessTestreq.context.get<Span>("otel.span") returns the active span to a handler.
    • RouteAttributeTest — (only if Option A is implemented) http.route attribute populated from stashed pattern.

Verification commands (from CONTRIBUTING.md + AGENTS.md)

./gradlew :kamellia-opentelemetry:test      # new module tests
./gradlew test                              # full suite — ensure no regressions
./gradlew detekt                            # lint (must pass, do NOT bypass rules)
./gradlew spotlessCheck                     # formatting
./gradlew build                             # all of the above

OpenTelemetry Integration Research

Summary

Kamellia is a small, functional Netty-based HTTP framework whose entire request lifecycle flows through two composable function types: RawHandler = suspend (Request) -> Response and Middleware = (RawHandler) -> RawHandler. Because all request handling is already funnelled through composeMiddlewares inside KamelliaHandler.channelRead0, the cleanest and most idiomatic way to add OpenTelemetry is a new kamellia-opentelemetry module that exposes a telemetryMiddleware(...) producing a SERVER span per request, plus a small amount of hooking in KamelliaHandler for inbound context extraction (W3C traceparent propagation) and exception recording (the framework's try/catch around the handler is the only place errors are observed). The framework already has all the seams needed; the only real friction points are (1) the per-request CoroutineScope(dispatcher) in the Netty handler, which breaks implicit OTel Context propagation across suspend boundaries unless we bridge it explicitly, and (2) the absence of a version catalog, so OTel BOM coordination must be done inline per module.

Architecture Overview

The request path is a clean pipeline. Everything interesting for tracing happens between RequestConverter.convert and sendResponse:

TCP conn
  → Netty pipeline: HttpServerCodec → HttpObjectAggregator → KamelliaHandler
       (kamellia-netty/.../NettyServer.kt:42-49)
  → KamelliaHandler.channelRead0  (kamellia-netty/.../KamelliaHandler.kt:50)
       ├─ RequestConverter.convert(msg)          → Request           [extract carrier here]
       ├─ router.match(request)                  → RouteMatch?       [span name = route pattern]
       ├─ composeMiddlewares(middlewares, handler)→ RawHandler       [telemetryMiddleware sits here]
       │     foldRight: mw1(mw2(...(routeHandler)))
       ├─ handler(requestWithParams)             → Response          [the span's scope]
       │     try { ... } catch (e) { errorHandler(e, request) }      [record exception here]
       └─ sendResponse(ctx, response)            → writes Netty response

The handler-as-a-function design means a span can wrap the entire downstream call simply by being the outermost middleware. The one wrinkle: each request runs in its own CoroutineScope(ctx.executor().asCoroutineDispatcher()) (KamelliaHandler.kt:53-55), so OTel's thread-local Context.current() is not automatically carried — we must capture/restore it explicitly or use kotlinx-coroutines' asContextElement().

Key Files

File Purpose / why it matters for OTel
kamellia-netty/src/main/kotlin/io/github/kamellia/netty/KamelliaHandler.kt:50 Sole request entry point. Where coroutine scope is created (:53-55), router match happens (:59), middlewares composed (:61-72), handler invoked under try/catch (:76-80), exception observed (:79). The place to extract inbound context and to record uncaught exceptions on the span.
kamellia-netty/.../KamelliaHandler.kt:104-108 Executor.asCoroutineDispatcher() — the custom dispatcher. Relevant if we want OTel Context to flow across suspend resumptions.
kamellia-middleware/src/main/kotlin/io/github/kamellia/middleware/Middleware.kt:5 typealias Middleware = (RawHandler) -> RawHandler. The contract a telemetryMiddleware must implement.
kamellia-middleware/.../MiddlewareComposer.kt:5-6 composeMiddlewares foldRight. Confirms outermost-registered middleware wraps everything (order matters for span scope).
kamellia-middleware/.../LoggingMiddleware.kt:14-38 The closest existing analog: a config-driven middleware factory loggingMiddleware(config): Middleware that measures duration and reads request/response. Copy this shape for telemetry. Note withLoggingContext (MDC) usage — OTel has its own MDC bridge.
kamellia-middleware/.../CorsMiddleware.kt:8-22 Second middleware example: shows the request-inspection + response.copy(headers = ...) pattern.
kamellia-core/src/main/kotlin/io/github/kamellia/core/Request.kt:5-13 Request data class. headers: Map<String,String> (lowercased keys, see RequestConverter:18) is the OTel extract carrier. context: Context is request-scoped state where a Span/OTel Context can be stashed for handler access.
kamellia-core/.../Context.kt:3-19 @JvmInline value class Context(MutableMap<String,Any>) with get/set. The natural place to expose the active Span to user handlers (e.g. req.context.get<Span>("otel.span")).
kamellia-core/.../Response.kt:22-25 Response(status, headers, body). status.code is the value for the http.response.status_code attribute and for span status (>=500 → ERROR).
kamellia-core/.../HttpStatus.kt Status enum with .code. Source for response status attribute.
kamellia-core/.../HttpMethod.kt:3-16 Method enum → http.request.method attribute.
kamellia-router/src/main/kotlin/io/github/kamellia/routing/Router.kt:13-24 match(request): RouteMatch?. Important: the route pattern (low-cardinality span name like GET /users/:id) is NOT currently exposed on RouteMatch — see Gotchas.
kamellia-router/.../RouteMatch.kt:5 data class RouteMatch(pathParams, handler) — lacks the matched pattern string. To name spans by route template (best practice, avoids high cardinality) this likely needs the pattern added.
kamellia-router/.../Route.kt Holds method, pattern, matcher, handler. route.pattern is the low-cardinality template we'd want for span names.
kamellia-app/.../Kamellia.kt:16-27 use(middleware) registration + start(port, errorHandler). Where users would call app.use(telemetryMiddleware(openTelemetry)).
kamellia-netty/.../RequestConverter.kt:17-20 Lowercases all header keys — relevant because the W3C carrier getter must look up traceparent lowercased (it already is).
settings.gradle.kts:7-18 Module list — a new kamellia-opentelemetry module must be added here.
build.gradle.kts:8-30 Root build: subprojects block, JVM toolchain 21, spotless/detekt config inherited by every module. New module inherits this automatically.
kamellia-netty/build.gradle.kts / kamellia-middleware/build.gradle.kts Dependency style reference — inline coordinates with hardcoded versions, no version catalog. New OTel module follows same convention.

Patterns & Conventions

  • Everything is a function. Handlers and middleware are typealiases over suspend lambdas. New cross-cutting concerns are added as Middleware factories returning Middleware, never as inheritance/plugin registries. OTel must follow this: fun telemetryMiddleware(...): Middleware.
  • Config via small data classes. Each middleware takes an optional config object with defaults (LoggingConfig, CorsConfig). Mirror with e.g. TelemetryConfig(captureHeaders: Boolean = false, ...).
  • Module-per-integration. Optional integrations live in their own module (kamellia-kotlinx-serialization, kamellia-kotlin-result, kamellia-arrow-kt). OTel is an optional dependency → it belongs in a new kamellia-opentelemetry module, not bolted onto kamellia-core or kamellia-middleware. This keeps OTel out of the dependency graph for users who don't want it.
  • api(project(...)) for transitive exposure, implementation(...) for libs. See kamellia-netty/build.gradle.kts:1-9. A telemetry module would api(project(":kamellia-core")), api(project(":kamellia-middleware")), and implementation/api the OTel artifacts.
  • Logging is io.github.oshai:kotlin-logging + logback (test only). MDC is populated via withLoggingContext(...) (LoggingMiddleware.kt:19). For trace/log correlation, OTel ships opentelemetry-logback-mdc / a logback appender; alternatively just set trace_id into MDC in the telemetry middleware to match the existing logging idiom.
  • Naming: package root io.github.kamellia, group io.github.kamellia, sub-package per concern (core, routing, middleware, netty). A new module would use io.github.kamellia.opentelemetry.
  • No fully-qualified names in code (AGENTS.md) — use imports.
  • Error handling rules (AGENTS.md): MUST NOT catch Throwable, MUST NOT use runCatching. The existing code catches Exception (KamelliaHandler.kt:78, :98). OTel exception recording must stay within catch (e: Exception) and re-throw or hand to errorHandler exactly as today — do not widen the catch.
  • detekt is strict and must not be suppressed (AGENTS.md). New code must pass detekt; watch for TooGenericExceptionCaught (already @Suppress-ed in the handler) and magic-number rules.

Similar Implementations

  • loggingMiddleware (LoggingMiddleware.kt:14-38) is the single best template. It already: reads req.method/req.path, wraps the downstream call in measureTimedValue { next(req) }, reads response.status.code, and threads a per-request id through MDC. A telemetryMiddleware is structurally the same but starts a span before next(req), sets attributes, and ends the span in a finally. Reuse the duration-measurement and the "wrap next, inspect response" shape.
  • corsMiddleware (CorsMiddleware.kt:8-22) demonstrates conditional short-circuit (returning a response without calling next for OPTIONS) and response.copy(headers = ...). Use the copy pattern if you want to inject trace headers (e.g. traceresponse) into responses.
  • Existing optional modules (kamellia-kotlinx-serialization, kamellia-kotlin-result, kamellia-arrow-kt) are the template for adding kamellia-opentelemetry: a directory under root, src/main/kotlin/io/github/kamellia/<name>/, a build.gradle.kts with api(project(...)) deps, and an entry in settings.gradle.kts. No build glue beyond that — root subprojects handles toolchain/spotless/detekt/test config.
  • There is no existing tracing/metrics code in the repo (grep finds nothing OTel-related), so this is greenfield; nothing to refactor away.

Recommended Approach (concrete)

  1. New module kamellia-opentelemetry. Add to settings.gradle.kts:7-18. build.gradle.kts:

    • api(project(":kamellia-core")), api(project(":kamellia-middleware"))
    • OTel via BOM (versions current as of June 2026): io.opentelemetry:opentelemetry-bom:1.62.0 (platform), then opentelemetry-api, opentelemetry-context; for users who configure their own SDK, only -api/-context are strictly needed at framework level. Add io.opentelemetry.semconv:opentelemetry-semconv (semantic-conventions-java ~1.42.0) for the attribute key constants. The SDK (opentelemetry-sdk, exporters) belongs in the user's app / examples, not in the library module, so Kamellia stays exporter-agnostic.
    • Because there is no version catalog, declare the BOM with Gradle platform(...) inline and let the BOM manage transitive versions; this matches the project's "hardcoded version per module" style while still keeping OTel artifacts in sync.
  2. fun telemetryMiddleware(openTelemetry: OpenTelemetry, config: TelemetryConfig = TelemetryConfig()): Middleware modeled on loggingMiddleware:

    • Get a Tracer from openTelemetry.getTracer("io.github.kamellia").
    • Build a SERVER-kind span (tracer.spanBuilder(name).setSpanKind(SpanKind.SERVER)).
    • Span name: prefer "<METHOD> <routePattern>". This requires the route pattern, which is not on RouteMatch today (see Gotchas). Fallback: req.method.name only (avoid raw req.path to prevent high-cardinality span names).
    • Set semconv attributes from Request: http.request.method, url.path, url.scheme, server.address (from host header), network.protocol.version. After next(req) set http.response.status_code. Set span status ERROR when status.code >= 500.
    • Wrap next(req) in try { span.makeCurrent().use { next(req) } } catch (e: Exception) { span.recordException(e); span.setStatus(ERROR); throw e } finally { span.end() }.
    • Optionally req.context.set("otel.span", span) so handlers can add custom attributes via Context (Context.kt:8).
  3. Inbound context extraction (distributed tracing) — needs a small change in KamelliaHandler.channelRead0 or can be done inside the middleware reading req.headers:

    • Use openTelemetry.propagators.textMapPropagator.extract(Context.current(), request.headers, getter) with a getter over the lowercased header map (RequestConverter already lowercases keys, so traceparent lookup is straightforward). Make the extracted io.opentelemetry.context.Context the parent of the SERVER span. Doing this inside the middleware keeps KamelliaHandler untouched, which is preferable given the framework's "no special cases in the handler" style.
  4. Coroutine context propagation (the real gotcha). OTel's Context.makeCurrent() is thread-local; Kamellia resumes suspend functions on the Netty executor via the custom dispatcher (KamelliaHandler.kt:104-108). makeCurrent().use { next(req) } only covers the synchronous portion up to the first suspension. To keep the span current across suspend boundaries, add io.opentelemetry:opentelemetry-extension-kotlin and use withContext(openTelemetryContext.asContextElement()) { next(req) }. This is the single most important correctness detail for this framework's coroutine model.

  5. Metrics (optional, same module). A second factory or the same middleware can record an http.server.request.duration histogram via openTelemetry.getMeter(...), reusing the measureTimedValue pattern already in LoggingMiddleware.kt:24.

  6. Wiring for users: app.use(telemetryMiddleware(openTelemetry)) in Kamellia (Kamellia.kt:16). Register it first so it is the outermost wrapper and the span covers all other middleware.

Gotchas & Constraints

  • Route pattern is not exposed for span naming. RouteMatch (RouteMatch.kt:5) carries only pathParams + handler; Router.match (Router.kt:13-24) discards route.pattern. Best-practice span naming (GET /users/:id, low cardinality) requires plumbing the pattern through — add pattern: String to RouteMatch and populate it in Router.match. Without this, span names must fall back to method-only, or risk cardinality explosion if req.path is used. This is the one cross-module change OTel really wants. Touching RouteMatch affects Router, KamelliaHandler (line 74 uses routeMatch.handler), and router tests.
  • Per-request CoroutineScope breaks implicit context flow. See approach step 4. If skipped, spans created in the middleware will not be current inside async handlers, so child spans the user creates won't nest correctly. Must use asContextElement() / extension-kotlin.
  • Span lifecycle vs. streamed responses. Body.Streamed (Body.kt:17) is written after the handler returns, via ResponseConverter.writeStreamed — but note the current sendResponse (KamelliaHandler.kt:89-95) always calls convertFull, which errors on Body.Streamed (ResponseConverter.kt:24). So streaming isn't wired end-to-end yet. For tracing, ending the span in the middleware's finally (when next returns the Response object) will close the span before a streamed body is flushed — acceptable for now since streaming is unfinished, but a known limitation to revisit when streaming lands.
  • Connection: close on every response (KamelliaHandler.kt:91) — no keep-alive currently, so no need to worry about span-per-connection vs span-per-request multiplexing.
  • exceptionCaught (KamelliaHandler.kt:97-101) only does printStackTrace() and is outside any span scope (channel-level, pre/post coroutine). Transport-level errors here won't be captured by a request-scoped span; that's fine, but don't expect OTel to see them without extra work.
  • No version catalog. OTel versions must be declared inline (BOM platform(...)), consistent with the rest of the project. Don't introduce libs.versions.toml for this change alone unless the team wants a broader migration.
  • detekt / AGENTS.md rules: keep catch (e: Exception) (never Throwable), no runCatching, no detekt suppressions added to bypass rules, use imports not FQNs, pick the right class kind (the middleware factory returns a lambda; config should be a data class).
  • Don't put OTel in kamellia-core/kamellia-middleware. That would force the dependency on all users. Keep it isolated in kamellia-opentelemetry (matching kamellia-kotlin-result etc.).

Relevant Tests

  • Middleware test pattern: kamellia-middleware/src/test/.../LoggingMiddlewareTest.kt:25-159 is the model. It builds a fake Request via a private req(...) helper (:46-55), wraps a RawHandler lambda with middleware(handler), invokes it under kotlinx.coroutines.test.runTest, and asserts on observed side effects. For OTel, swap the logback ListAppender for an OTel InMemorySpanExporter + SdkTracerProvider (from opentelemetry-sdk-testing) and assert on exported spans (name, kind=SERVER, attributes, status). MiddlewareIntegrationTest.kt shows composing multiple middleware through composeMiddlewares.
  • Integration test pattern: kamellia-app/src/test/.../KamelliaIntegrationTest.kt:23-107 drives the app via app.router().match(req(path)) then match.handler(req) — no real socket. A telemetry integration test can do the same: register telemetryMiddleware, call through, and assert spans were exported. req(...) helper at :30-38 is reusable.
  • Test deps: root build.gradle.kts:26-30 adds kotlin("test") and kotlinx-coroutines-test to every module's testImplementation automatically. The new module would additionally add io.opentelemetry:opentelemetry-sdk-testing as testImplementation.
  • Verification process (.claude/rules/development-process.md): after implementing, run ./gradlew compileTestKotlin, then the test-runner and code-quality-checker subagents.
  • No existing tests reference OTel; nothing to avoid breaking except the router tests if RouteMatch gains a pattern field (kamellia-router/src/test/.../RouterTest.kt).

Suggested Dependency Coordinates (June 2026)

(For the new kamellia-opentelemetry module — versions from current Maven Central.)

  • platform("io.opentelemetry:opentelemetry-bom:1.62.0")
  • io.opentelemetry:opentelemetry-api
  • io.opentelemetry:opentelemetry-context
  • io.opentelemetry:opentelemetry-extension-kotlin ← coroutine asContextElement()
  • io.opentelemetry.semconv:opentelemetry-semconv (semantic-conventions-java ~1.42.0) ← attribute keys
  • test: io.opentelemetry:opentelemetry-sdk-testing
  • App/examples only (not the library): opentelemetry-sdk, an exporter (e.g. OTLP), or opentelemetry-instrumentation-bom:2.28.1 if pulling in higher-level helpers.

Sources:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment