Logs for Ktor services, shaped for a coding agent to read.
Unlike a traditional logging stack, tracy is one self-contained binary compiled with Kotlin/Native
and one SQLite file. No JVM, no Elasticsearch, no log shipper. A KMP plugin lives inside your
service, correlation travels as a standard W3C traceparent, and MCP is the primary read
path — so the agent debugging your bug asks for the trace instead of being handed a dashboard
link.
Same niche as katcher (crashes) and
metrik (metrics): between "kubectl logs is enough" and
"let's run Loki + Promtail + Grafana".
Status: running on a staging cluster with four services reporting to it. Everything below is written and covered by 293 tests, a coding agent reads it over MCP, and the server has been taking real traffic for a day.
Two things that number does not cover, stated because a status line that only lists wins is not a status line. Those four services are quiet — a day of them is on the order of a hundred kilobytes, so the volume figures further down are still arithmetic rather than measurements, and the sampling and screening behaviour has not been exercised at any interesting scale. And
clock_skew_mscurrently measures the age of a record on arrival rather than the difference between clocks; a retry inflates it. That is M-110.
tracy answers the question you actually have during an incident — what happened to this request, or to this order — rather than handing you a search box over a pile of text.
- Structured logging where the message template and the data are separated at write time, so what the developer wrote and what a caller supplied never get confused later.
- End-to-end traces — you have a
traceId, you get the whole chain: a tree of spans with durations plus every log line of that request, across every service, in one call. Correlation travels as a standardtraceparentheader, so tracy interoperates with anything else that propagates it. - Business entity lookup — support never brings you a
traceId; they bring anorder_id. Mark a field as an entity key and you get the whole history of that entity across services and across separate traces — including the parts whose log bodies were sampled away. - Search and grouping — FTS5 full-text search over message templates, time-window queries across services, and exact event frequencies over time, inside the same native binary.
- MCP access — seven read-only tools with a hard context budget, a two-phase content release and a static screen over untrusted text.
- Ktor 3.5.2, CIO server engine
- Kotlin/Native —
linuxX64,linuxArm64,macosArm64;jvmfor development - SQLite via sqlx4k, with FTS5, the
trigramtokenizer andcontentless_delete - MCP Kotlin SDK — streamable HTTP, stateless
- kotlinx.serialization, kotlin-logging, logback (JVM only)
Run the server. It is one binary and one file; the only required setting is the ingest key, because a log collector that quietly started without one is indistinguishable from a healthy one until the first incident.
docker run -p 8080:8080 -e TRACY_INGEST_KEY=dev-key ghcr.io/youndie/tracy:latestAdd the agent to a Ktor service. The server plugin is a module of its own, so that an app — which
writes logs too and has no server — does not pull ktor-server-core:
implementation("io.github.youndie.tracy:agent-ktor-server:<version>") // brings :agent with itval config = AgentConfig(
service = "orders-api",
apiKey = ingestKey, // from your own configuration; there is no System.getenv on native
endpoint = "https://tracy.example",
instanceId = podName, // HOSTNAME in a cluster: a record is traceable back to a restart
)
val tracy = TracyAgent(config, clock = { Clock.System.now().toEpochMilliseconds() })
// Nothing leaves the process until this runs: the agent buffers, the delivery loop sends.
// It also stops on `ApplicationStopping` and flushes what is buffered — the records explaining a
// shutdown are the least replaceable ones there are, and until 0.3 nothing called `stop()` at all.
// Keep the handle if you have a shutdown sequence of your own: on Kotlin/Native
// `ApplicationStopping` fires *before* the engine drains, so the last records still need
// `delivery.stop()` from wherever your order puts telemetry.
val delivery = startTracyDelivery(tracy, config)
install(Tracy) { agent = tracy } // incoming spans, trace context, tail samplingThe client plugin belongs to the HttpClient you make outgoing calls with — that is what makes
the chain continue on the other side:
val http = HttpClient {
install(TracyClient) { agent = tracy } // outgoing calls carry `traceparent`
}In an app there is no incoming request to open a trace, so the app opens one around what the person did. Outgoing calls inside it carry the trace, and the service continues it:
tracy.action("checkout.submit") {
http.post("https://api.example/orders") { /* … */ }
}An app is not told it is going away — it goes to the background and is suspended or killed. Hook the
platform's own signal so the last records leave first (Android ON_STOP, iOS
didEnterBackground, the browser's visibilitychange/pagehide, a desktop JVM's shutdown):
val delivery = TracyDelivery(tracy, config).start(appScope)
delivery.flushWhenBackgrounded()An app writes with a client key, and the server caps a client's batch at 64 KiB by default
(TRACY_CLIENT_MAX_BATCH_BYTES), so give the app's AgentConfig a maxBatchBytes below it. In the
browser the agent caps it at 60 KiB anyway: every send is keepalive, so it outlives a closing page,
and the browser allows such a request 64 KiB of body.
Then log. The message is a constant you wrote; the values go into fields, and that separation is
what keeps a caller's input out of the template table later. Logging is suspend by design — it
runs inside your request, and the trace context lives in the coroutine, because Kotlin/Native has
no MDC and no ThreadContextElement:
val log = tracy.logger("OrdersRouting")
post("/orders") {
val order = createOrder(call.receive())
log.info("order created") { field("orderId", order.id, indexed = true) }
call.respond(order)
}indexed = true makes orderId an entity key — that is what later answers "show me everything
that ever happened to order 12345", across services and across separate traces.
Point a coding agent at it:
claude mcp add --transport http tracy https://tracy.example/mcp \
--header "Authorization: Bearer $TRACY_MCP_TOKEN"The Bearer scheme is part of the header, not decoration: a bare token without it is refused with
401. With no TRACY_MCP_TOKEN set on the server there is no MCP endpoint at all — the feature is
off by default rather than open by default.
- Not an APM. Spans exist, but only at the boundaries — one per incoming request, one per
outgoing HTTP call, plus whatever you wrap in
withSpanyourself. There is no auto-instrumentation of database drivers, caches or queues, and no aggregate analytics over spans: tracy shows you one trace, not statistics about traces. Time nobody instrumented shows up as unattributed, rather than silently disappearing. - Not a log pipeline. tracy does not replace your stdout logs — it runs alongside them, on
purpose: an in-process buffer cannot survive a
SIGKILL, and stdout can. - Not a complete capture on native. Measured, not assumed: on Kotlin/Native tracy sees what
you write through its API or through kotlin-logging. Ktor's own logger and
printlngo to stdout and nowhere else. On macOS and iOS, where kotlin-logging writes to os_log, a logger obtained beforecaptureKotlinLogging()is not seen either — call it before the first logger. On the JVM the SLF4J appender does see framework and library logs. Since framework logging is around 97% of the volume in practice, this is mostly the noise anINFOfloor would drop anyway — but it is a boundary, and you should know where it runs. - Not multi-tenant. One installation belongs to one team. Isolation means a second installation — which is cheap, because it is one binary and one file.
- Not an unlimited store. Retention is bounded by both age and file size, and sampling is on
by default. A service at 100 rps writing two lines per request produces roughly 5 GB of logs
per day; sampled and stored, that comes to roughly 145 MB per day, or a month of history in
about 4.3 GB — arithmetic, not a measurement. What is kept regardless of sampling:
everything at
WARNand above, every entity reference, and exact per-template counters — so "how often" and "was this entity touched" stay answerable even when the log body is gone. - Not a live tail. For watching a service right after a deploy,
kubectl logs -fis better than anything layered on top of batching, SQLite and an FTS index — and tracy deliberately leaves your stdout untouched so that keeps working. - Not an audit log of record. You can investigate with it, but sampling, retention limits, size-based eviction and redaction all work against audit guarantees. Anything that must be provably complete belongs somewhere else.
charts/tracy is a Helm chart for the server — see its README. Two
values fail the render rather than deploying something that looks alive: an empty ingest.key,
because the server refuses to start without one, and an empty hostname, because it renders
Host(` `) rules matching nothing and leaves the MCP endpoint with no host to check Host
against. Both failures are silent otherwise.
docs/README.md— layer mapdocs/research/research-architecture.md— verified facts, decisions and risks. Start here.BACKLOG.md— milestones M0…M8
Documentation is in Russian; code, comments and commits are in English.
MIT — see LICENSE.