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.
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.
| 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 |
composeMiddlewares — foldRight 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). |
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 theMiddlewaretypealias andRawHandler)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>")— forInMemorySpanExporterin tests.
- Top-level
fun nameMiddleware(config: NameConfig = NameConfig()): Middleware = { next -> { req -> ... } }. Configuration is adata classwith defaults (LoggingConfig,CorsConfig). - Side-effect logger/dependencies are file-private
vals (private val logger = KotlinLogging.logger {}). - Constants are
private const valat file top (REQUEST_ID_LENGTH). - The middleware returns the
response(possiblycopy(...)-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 onspan.end()which timestamps automatically.
- AGENTS.md: "You MUST NOT catch
Throwableexceptions. DON'T userunCatching." So the tracing middleware must usetry { ... } catch (e: Exception) { ... }andtry/finallyforspan.end(). This aligns withKamelliaHandler.kt:78which already catchesException(with@Suppress("TooGenericExceptionCaught")). - Pattern to record errors and rethrow (so the existing
ErrorHandlerinKamelliaHandlerstill 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:138hasTooGenericExceptionCaught: active: false, andSwallowedException: active: true— rethrowing means it's not swallowed, so this is safe.
- 4-space indent, 120 max line length, ktlint 1.8.0 (
build.gradle.kts:38-44). - No wildcard imports (
ktlint_standard_no-wildcard-importsis disabled, but detektWildcardImport: active: trueatdetekt.yml:483— so write explicit imports). MagicNumber: active: true(excludes tests) atdetekt.yml:334— HTTP status thresholds (500, 400) and OTel span attribute values that are numeric literals needprivate const valdeclarations.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.
| 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) |
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.
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.
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.
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.
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.routelater, inKamelliaHandler. Do tracing partly in the Netty layer instead of pure middleware.KamelliaHandler.channelRead0already hasrouteMatchin scope (KamelliaHandler.kt:59); it could stashrouteMatch's pattern intorequest.contextbefore invoking the composed middleware. Requires exposingpatternonRouteMatch(addval pattern: StringtoRouteMatch.kt:5and populate it inRouter.kt:19). The tracing middleware then readsreq.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
Middlewareto 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.
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).
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.
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.
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.
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.
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).
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.
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.
| 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. |
kotlin.test(kotlin.test.Test,assertEquals,assertTrue), JUnit Platform (useJUnitPlatform()inbuild.gradle.kts:33).- Coroutines:
kotlinx.coroutines.test.runTest(NOTrunBlocking) — see every middleware test. - No HTTP server is spun up in tests; the router + middleware composition is exercised directly. To test W3C
traceparentextraction, constructRequest(headers = mapOf("traceparent" to "00-<trace>-<span>-01"))and assert the resulting span's parentSpanContextmatches. 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).TraceContextExtractionTest—traceparentheader → parent span linkage.ActiveSpanAccessTest—req.context.get<Span>("otel.span")returns the active span to a handler.RouteAttributeTest— (only if Option A is implemented)http.routeattribute populated from stashed pattern.
./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