jimble

Logging#

Log.info("started");
Log.warn("configuration is missing: %s".formatted(key));
Log.debug("not found");
Log.error(cause, "could not save: id=%d".formatted(id));

Careful

{} placeholders do not work. This is not SLF4J's format, so write Log.info("id={}", id) and you get a literal {} in the output. Build the string with String.formatted(...).

Note

With Log.error(cause, "a description"), the message is the exception's. The description you wrote goes into the structured data, and the bundled encoder lays them out together on the first line.

[2026-09-07 13:12:19]-[main] error could not save / java.lang.IllegalStateException: cannot write

Logger names#

Logger What comes out of it
access The access log (one line per request)
access.bot The bot access log
error Log.error(...)
io.jimble.util.log.Log Log.info / warn / debug / trace
Any name you like Chosen with Log.appInfo("my.audit", ...) and friends

Trap

The class that called Log.info and the rest does not become the logger name. It is always io.jimble.util.log.Log. To split by purpose, use Log.appInfo("a name", ...).

What is in every log line, always#

Key What it holds
request_id The execution ID (- outside a request)
sql_execute_count How many SQL statements this request issued
sql_execute_time Their total time (milliseconds)
local_info The server's own IP and host name

The point is that the SQL count and time are in every log line. N+1 is built to be something "you can see in the log" (requirement NF-O-02).

The access log#

One line per request, written at the end of it. It is written even when no route matched (404s are kept too).

Key What it holds
method / path / query The request
status The status code
elapsed How long it took (milliseconds)
matched Whether a route matched
bot Whether it was judged a bot
{"logger_name":"access","level":"INFO","message":"GET /users/42 200",
 "request_id":"m1abcd-xyz-1","sql_execute_count":2,"sql_execute_time":4.0,
 "method":"GET","path":"/users/42","query":"","status":200,"elapsed":12.34,
 "matched":true,"bot":false}

Trap

The client's IP and User-Agent are not in there. local_info is the server's own. If you need them, add them with Log.addFieldProvider(...).

Splitting out the bots#

With server.bot_access_log (default true) on, bot lines go to access.bot. The judgement is a match against a User-Agent list (crawler-user-agents).

Tip

Crawlers can be a sizeable share of the total. Split them out and you can count human traffic on its own. Set it to false to keep them together.

Turning it off#

server.access_log = false stops it writing anything at all.

server {
	access_log = false
}

This is the largest single piece of a request (about 40% of what it allocates — around 3,400 bytes). Turning it off stops both the work of building the line and the bot judgement (matching the User-Agent). Requests per second move by around 10% (52,537 → 57,030 on a 10-core Mac at 8 connections). That figure varies by machine, so measure it on yours before turning it off (jimble-load/load.sh measures the two side by side).

Metrics and tracing stay. The access log is the only thing that goes.

Trap

Leave the default at true. With it off, you have no way to find out afterwards what happened. Neither the 500s nor who hit which path are kept. Turn it off only when something in front of you (a load balancer, nginx) already keeps the same thing, and you have measured and found it isn't enough.

The execution ID#

It is base-36 time-random-sequence, and one is issued per request (and per WebSocket message, and per batch). It is carried around in a ScopedValueMDC is not used.

The time comes first, so sorting the IDs sorts them by time.

Note

It is not put in the response headers. To make "find me the log for the error on this screen" work, write context.response().setResponseHeader("X-Request-Id", context.executionId()) yourself in an after.

SQL logging#

The count and time of SQL statements are tallied automatically (the table above).

Careful

The SQL text itself and the bind values are not logged. There is no slow-query threshold either. When you want them, look on the DB side (general_log / slow_query_log).

Set log.db = true and whatever the app writes with DBLog.save(db, data) goes into the db_log table. It is not a feature that records SQL automatically.

Configuring logback#

jimble carries logback and the encoders, but the configuration file is the app's. The jimble new skeleton creates conf/logback.xml (conf/ goes into the jar).

<configuration>

	<!-- The form people read -->
	<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
		<encoder>
			<pattern>%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n</pattern>
			<charset>UTF-8</charset>
		</encoder>
	</appender>

	<!-- Exceptions. Prints the stack trace indented (bundled with jimble) -->
	<appender name="error" class="ch.qos.logback.core.ConsoleAppender">
		<target>System.err</target>
		<encoder class="io.jimble.util.log.encoder.LogbackErrorEncoder"/>
	</appender>

	<!-- The form machines read. One JSON object per line (bundled with jimble) -->
	<appender name="json" class="ch.qos.logback.core.ConsoleAppender">
		<encoder class="io.jimble.util.log.encoder.LogbackJsonEncoder"/>
	</appender>

	<logger name="access" level="INFO" additivity="false">
		<appender-ref ref="json"/>
	</logger>

	<logger name="access.bot" level="INFO" additivity="false">
		<appender-ref ref="json"/>
	</logger>

	<logger name="error" level="ERROR" additivity="false">
		<appender-ref ref="error"/>
	</logger>

	<root level="INFO">
		<appender-ref ref="console"/>
	</root>

</configuration>

Trap

access.bot is a child of access. Drop the additivity="false" and the bot lines go to both (you double-count while thinking you are counting humans).

Careful

Leave the configuration file out and you get logback's defaults, where the access log and the app's own logs land in the same place, mixed together.

To split by environment, pass -Dlogback.configurationFile=conf/logback.prod.xml instead of using logback.xml.

Catching it in tests#

@BeforeEach
void captureLog () {

	Log.sink((loggerName, level, message, data, throwable) ->
		logs.add(new Entry(loggerName, level, message, data)));

}

@AfterEach
void restoreLog () {

	Log.resetSink();

	// 設定を触るテストがあるので、必ず戻す(残すと後ろのテストが理由なく落ちる)
	Conf.reload();

}

Log.sink(...) replaces the whole thing. Do not forget Log.resetSink() in @AfterEach.

Metrics#

Metrics.snapshot() returns everything it has right now.

get("/metrics", context -> context.response().json(Metrics.snapshot()));

Careful

jimble does not register a /metrics route for you. Whether to expose it, and behind what authentication, is the application's call — if the framework owned the route you could not close it when you needed to (same reasoning as the health check). The one-liner above is readable by anyone; put it behind your network or your auth.

The shape:

{
  "counter": { "http.request": 1234, "http.status.2xx": 1230, "http.status.5xx": 4 },
  "latency": {
    "http.GET /posts/{id}": {
      "count": 1200, "sum_ms": 4321.0, "max_ms": 812.3,
      "p50_ms": 10, "p95_ms": 100, "p99_ms": 500,
      "bucket": { "1": 300, "5": 700, "10": 150, "50": 40, "100": 8, "500": 1, "1000": 1, "5000": 0, "over": 0 }
    }
  },
  "gauge": { "db.pool.main.active": 3, "db.pool.main.idle": 5 }
}

What you get without asking#

Name What
http.request Request count
http.status.2xx5xx Count per status class
http.GET /posts/{id} Latency distribution per route
db.pool.<name>.{active,idle,total,waiting} Connection pool usage
mq.<queue>.received / .completed / .error MQ counts, by how the message ended
mq.<queue> MQ latency distribution

Adding your own#

Metrics.count("posts.created");
Metrics.record("search.elapsed", elapsedNanos);
Metrics.gauge("cache.size", () -> cache.size());

Trap

What you pass to Metrics.gauge(...) must not do I/O. It is called on every Metrics.snapshot(), so if it touches the database you lose your metrics exactly when the database is congested — which is when you want them most. That is why jimble does not register the MQ backlog (MqQueue#pendingCount()) for you. Register it yourself if you want it anyway:

Metrics.gauge("mq.notice.pending", () -> noticeQueue.pendingCount());

Careful

Never build a name out of user input. Metrics.count(request.path()) fills the heap as soon as someone hits /aaa, /aab, … There is a cap of 1000 names; past it jimble warns once and stops counting — and your real routes stop being counted too. That is also why jimble keys latency on GET /posts/{id} rather than the raw path, and collapses everything unmatched into (unmatched).

Note

Distributions are fixed buckets (1 / 5 / 10 / 50 / 100 / 500 / 1000 / 5000ms, plus an overflow bucket) — individual samples are never stored. Memory does not grow with the number of samples, but a percentile can only name the bucket's upper bound (p95_ms: 100 means "100ms or less"). Samples in the overflow bucket report the observed maximum instead.

Note

Metrics.reset() is for tests. Calling it in a running application throws away everything counted so far.

Tracing#

Follow one request across services (requirement NF-O-05). You only pay for it if you use it.

// build.gradle.kts
implementation("io.jimble:jimble-otel:0.2.1")
public static void main (String[] args) {

    JimbleOtel.install("my-app", "http://localhost:4318");

    new JimbleServer(...).start();

}

That is all. Spans then appear for:

Span Name Kind
An HTTP request GET /posts/{id} server
One SQL statement SELECT post client
Putting a message on a queue mq.put notice producer
Handling a queued message mq notice consumer
One batch run batch daily-rollup internal

Add your own wherever you want more detail.

try (Span span = Tracing.start("convert image", SpanKind.internal)) {
    span.attribute("file", name);
    convert(file);
}

Note

An application that does not add it grows by zero bytes. jimble-core carries only the interfaces (Tracer / Span); the OpenTelemetry implementation lives in jimble-otel. While nothing is registered, Tracing.start(...) costs one static read and a branch — measured at 0 bytes allocated per call.

Across services#

An incoming traceparent (W3C Trace Context) is picked up automatically, and jimble's HTTP client adds it automatically on the way out.

// the other side's trace continues yours
new HttpGetExecutor("https://api.example.com/users").execute();

MQ is linked too. The request that queued the message and the process that handled it minutes later end up on the same trace. The traceparent is kept in its own column on the queue table — never inside data.

Careful

Applications using MQ get one extra column on the queue table (traceparent varchar(64)). MqTables.install(...) applies it at startup; there is nothing to run by hand.

For any other way of reaching out, pass it yourself.

String traceparent = Tracing.traceparent();   // null when tracing is off

Joining logs to traces#

While tracing is on, every log line carries trace_id and span_id. The execution ID (request_id) is unchanged, so anything already parsing your logs keeps working.

{"request_id":"mttvgm93-10676dj-1","trace_id":"4bf92f35...","span_id":"00f067aa...", ...}

Sampling#

At volume your backend will not keep up. Pass a ratio.

JimbleOtel.install("my-app", "http://localhost:4318", 0.1);   // one in ten

Note

The decision is made per trace, so you never get a trace with the middle missing.

Where it goes#

OTLP over HTTP (/v1/traces), protobuf encoded. An OpenTelemetry Collector, Jaeger, Grafana Tempo — anything that speaks OTLP will do.

Trap

Forgetting /v1/traces still works — jimble appends it. Without that, the endpoint just answers 404 and nothing arrives, with nothing in the log to tell you.

Note

Sending uses the JDK HttpClient. OpenTelemetry's default (okhttp) drags in okhttp 851KB + okio 374KB + kotlin-stdlib 1.7MB, so it is excluded. Adding jimble-otel costs 0.9MB.

Writing your own destination#

Tracer is a two-method interface. Write one to print to stdout, to feed an in-house system, or to inspect spans in a test. A RecordingTracer for tests ships in jimble-core.

RecordingTracer tracer = new RecordingTracer();
Tracing.use(tracer);

// ... the code under test ...

assertEquals("GET /posts/{id}", tracer.spans().get(0).name());

Configuration keys#

Key Default What it does
server.access_log true Writes the access log. false writes nothing at all
server.bot_access_log true Splits the bot access log out into access.bot
log.db false Enables DBLog.save(...)

Note

There are no configuration keys for log level, destination, or format. That is logback.xml's job.

When Japanese comes out garbled, add -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 (Running in production).