Skip to content

Commit aaaa677

Browse files
committed
TIKA-4839 - add micrometer to tika-server
1 parent 26381aa commit aaaa677

30 files changed

Lines changed: 1581 additions & 11 deletions

File tree

CHANGES.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
Release 4.1.0 - unreleased
22

3+
* tika-server can publish Prometheus metrics via Micrometer. Set
4+
--metricsPort <port> (or server.metrics.port in the JSON config) and a
5+
separate scrape listener serves GET /metrics on that port; unset, nothing
6+
is created. Meters: tika_server_requests_seconds (endpoint/method/status
7+
class, fixed 12-bucket histogram), request/response size summaries,
8+
tika_server_rejected_total (busy_429, crash_503, payload_413),
9+
tika_pipes_workers (busy/idle), tika_pipes_worker_restarts_total by
10+
reason (oom, timeout, crash, max_files, idle, connection_abandoned),
11+
tika_pipes_queue_depth, the parse port's Jetty thread pool, and the JVM
12+
binders. The scrape listener binds to the server host by default
13+
(server.metrics.host overrides). New public API in tika-pipes-core:
14+
RestartReason, ServerManager.markServerForRestart(RestartReason),
15+
PipesParser.getNumClients/getIdleClientCount/getRestartCount and
16+
AsyncProcessor.getQueueDepth. A forked PipesServer that shuts down after
17+
its idle socket timeout now exits with code 24 (PipesServer.IDLE_EXIT_CODE)
18+
instead of 0, so the parent can tell an idle exit from a requested
19+
shutdown; 0 remains the SHUT_DOWN reply (TIKA-4839).
20+
321
* The Kafka pipes iterator no longer stops at the first empty poll. A newly
422
subscribed consumer spends its first poll(s) joining the group and returns
523
empty even when the topic has a backlog, so the iterator could enqueue zero

docs/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
** xref:using-tika/cli/index.adoc[Command Line]
2121
** xref:using-tika/server/index.adoc[Tika Server]
2222
*** xref:using-tika/server/tls.adoc[TLS/SSL Configuration]
23+
*** xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus]
2324
** xref:using-tika/grpc/index.adoc[gRPC]
2425
** xref:using-tika/docker.adoc[Running Tika in Docker]
2526
* xref:pipes/index.adoc[Pipes]

docs/modules/ROOT/pages/using-tika/server/index.adoc

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ moment" apart from "this document broke a fork" and act accordingly: back off an
5656
their fetch rate, and autoscalers have a signal to scale on — none of which is possible against a
5757
server that can only get slower or fail blind.
5858

59+
The same signal is available as metrics -- busy workers, `429` rate, worker restarts by reason --
60+
when a metrics port is set; see xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus].
61+
5962
The cost is real and lands on you: backpressure only reports the capacity you configured.
6063
`numClients` is now an operational obligation, sized against both your request volume and your
6164
host's core count, as the note above says — undersized shows up as `429`s under load that should
@@ -206,11 +209,15 @@ The server starts on `localhost:9998` by default.
206209
|`-i <id>`, `--id <id>`
207210
|Server ID, written to the startup log. Defaults to a random UUID.
208211

212+
|`--metricsPort <port>`
213+
|Enable Prometheus metrics on this port. See
214+
xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus].
215+
209216
|`-?`, `--help`
210217
|Print the usage message.
211218
|===
212219

213-
NOTE: `-h`, `-p` and `-i` override the JSON config. Everything else — `allowPipes`,
220+
NOTE: `-h`, `-p`, `-i` and `--metricsPort` override the JSON config. Everything else — `allowPipes`,
214221
`allowPerRequestConfig`, CORS, TLS, timeouts — is JSON-only.
215222

216223
== Endpoints
@@ -574,5 +581,7 @@ explicitly with the combined total in mind).
574581
== Topics
575582

576583
* xref:using-tika/server/tls.adoc[TLS/SSL Configuration] — TLS and mutual authentication
584+
* xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus] — metrics for
585+
autoscaling and alerting
577586
* xref:migration-to-4x/migrating-tika-server-4x.adoc[Migrating Tika Server to 4.x] — breaking
578587
changes from 3.x
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
//
2+
// Licensed to the Apache Software Foundation (ASF) under one or more
3+
// contributor license agreements. See the NOTICE file distributed with
4+
// this work for additional information regarding copyright ownership.
5+
// The ASF licenses this file to You under the Apache License, Version 2.0
6+
// (the "License"); you may not use this file except in compliance with
7+
// the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
//
17+
18+
= Monitoring with Prometheus
19+
:toc:
20+
:toclevels: 3
21+
22+
Tika Server can publish operational metrics in the Prometheus text exposition format,
23+
via https://micrometer.io[Micrometer]. The metrics are built for two questions an operator
24+
of a parse fleet actually has: _is this server saturated_ (scale on that, not on CPU) and
25+
_are its forked workers dying_ (alert on that).
26+
27+
== Enabling
28+
29+
Metrics are off by default. Setting a metrics port turns them on; nothing else is needed.
30+
31+
[source,bash]
32+
----
33+
java -jar tika-server-standard-X.Y.Z.jar --metricsPort 9404
34+
----
35+
36+
or in `tika-config.json`:
37+
38+
[source,json]
39+
----
40+
{
41+
"server": {
42+
"port": 9998,
43+
"metrics": {
44+
"port": 9404,
45+
"host": "127.0.0.1",
46+
"commonTags": { "cluster": "blue" }
47+
}
48+
}
49+
}
50+
----
51+
52+
[cols="1,3"]
53+
|===
54+
|Key |Meaning
55+
56+
|`port`
57+
|The scrape port. Unset means metrics are entirely off: no registry, no meters, no
58+
listener. `--metricsPort` on the command line overrides the JSON value. Must differ from
59+
the server port.
60+
61+
|`host`
62+
|Bind address for the scrape listener. Defaults to the server's own host (`-h`), so a
63+
server started with `-h 0.0.0.0` in a container exposes its metrics on the pod IP with no
64+
extra configuration. Set this to `127.0.0.1` to restrict scraping to a sidecar.
65+
66+
|`commonTags`
67+
|Labels added to every series, for example a cluster or tier name.
68+
|===
69+
70+
Metrics are served on a *separate port* from the parse endpoints, on purpose. The parse
71+
port receives untrusted documents; the metrics port should be reachable only by your
72+
scraper. Keep them apart at the network layer (a Kubernetes `NetworkPolicy`, a security
73+
group). The scrape listener answers only `GET /metrics` -- every parse endpoint is `404`
74+
there, and `/metrics` is `404` on the parse port. It also has its own small thread pool,
75+
so a scrape never waits behind a slow parse.
76+
77+
The scrape listener is plain HTTP even when the parse port uses
78+
xref:using-tika/server/tls.adoc[TLS].
79+
80+
== Probes
81+
82+
Point Kubernetes liveness and readiness probes at the parse port (for example
83+
`GET /version`, or `GET /status` when that endpoint is enabled), never at `/metrics`. A
84+
scrape target is not a health check: the listener stays up while the parser is failing,
85+
and a metrics misconfiguration must not take a healthy parser out of rotation.
86+
87+
== Scrape configuration
88+
89+
[source,yaml]
90+
----
91+
scrape_configs:
92+
- job_name: tika-server
93+
static_configs:
94+
- targets: ['tika-1:9404', 'tika-2:9404']
95+
----
96+
97+
For the Prometheus Operator, a `ServiceMonitor` selecting a Service that exposes the
98+
metrics port works the same way.
99+
100+
== Meters
101+
102+
Durations are Micrometer timers exported in seconds with a fixed histogram of twelve
103+
buckets (10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 30s, 60s, 120s), so
104+
`histogram_quantile` works and the series count per pod stays small. Every label value is
105+
drawn from a fixed set -- endpoint names, status classes, enum names -- never from the
106+
request, so cardinality cannot grow with traffic.
107+
108+
=== HTTP (parse port)
109+
110+
[cols="2,1,2,3"]
111+
|===
112+
|Meter |Type |Labels |Meaning
113+
114+
|`tika_server_requests_seconds`
115+
|timer
116+
|`endpoint`, `method`, `status`
117+
|Every request on the parse port, timed from before routing to the response. `endpoint`
118+
is the first path segment when it is one of the server's endpoints (`tika`, `rmeta`,
119+
`meta`, `unpack`, `detect`, `language`, `mime-types`, `detectors`, `parsers`, `version`,
120+
`status`, `pipes`, `async`), `other` for any other resource, `unmatched` for a `404`
121+
that matched nothing. `status` is the class: `2xx`, `4xx`, `5xx`.
122+
123+
|`tika_server_request_size_bytes`
124+
|summary
125+
|`endpoint`
126+
|Request body size from `Content-Length`; chunked uploads have none and are not recorded.
127+
128+
|`tika_server_response_size_bytes`
129+
|summary
130+
|`endpoint`
131+
|Bytes of the response entity, before any compression.
132+
133+
|`tika_server_rejected_total`
134+
|counter
135+
|`reason`
136+
|Requests refused for capacity reasons, by the status the server already uses to encode
137+
them: `busy_429` (the `/async` queue was full, or no fork was free within
138+
`maxWaitForClientMillis`), `crash_503` (the fork serving the request OOM'd, timed out or
139+
crashed), `payload_413` (body over `maxRequestSizeBytes` or the IPC payload limit).
140+
141+
|`tika_server_tasks_active`
142+
|gauge
143+
|
144+
|Parse/detect tasks in flight right now.
145+
146+
|`tika_server_tasks_started_total`
147+
|counter
148+
|
149+
|Tasks started since the server came up.
150+
151+
|`jetty_threads_*`
152+
|gauges
153+
|
154+
|The parse port's request thread pool. `jetty_threads_jobs` (queued jobs) is HTTP-level
155+
backpressure: requests waiting for a thread.
156+
|===
157+
158+
=== Forked workers
159+
160+
[cols="2,1,2,3"]
161+
|===
162+
|Meter |Type |Labels |Meaning
163+
164+
|`tika_pipes_workers`
165+
|gauge
166+
|`state`
167+
|Forked pipes workers `busy` and `idle`. `busy / (busy + idle)` sustained near 1 means the
168+
server is at capacity; see xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing]
169+
before raising `numClients`.
170+
171+
|`tika_pipes_worker_restarts_total`
172+
|counter
173+
|`reason`
174+
|Forked workers restarted, by why: `oom`, `timeout`, `crash` (an unexplained exit),
175+
`max_files` (routine recycling after `maxFilesProcessedPerProcess`), `idle` (the worker
176+
shut itself down after `socketTimeoutMillis` without a request -- exit code 24 -- and was
177+
started again on the next one), `connection_abandoned` (a request was interrupted mid-flight). Alert on
178+
`oom`, `timeout` and `crash`; `max_files` and `idle` are expected.
179+
180+
|`tika_pipes_queue_depth`
181+
|gauge
182+
|
183+
|Tuples accepted by `/async` and not yet picked up by a worker. Only present when the
184+
`async` endpoint is enabled.
185+
|===
186+
187+
=== JVM and process
188+
189+
The standard Micrometer binders are enabled: `jvm_memory_*`, `jvm_gc_*`, `jvm_threads_*`,
190+
`process_cpu_usage`, `system_cpu_usage`, `process_files_*`, `process_uptime_seconds`.
191+
These describe the server JVM only, not the forked workers.
192+
193+
== What to scale and alert on
194+
195+
* Saturation, for an autoscaler: `tika_pipes_workers{state="busy"}` as a ratio of the
196+
total, the rate of `tika_server_rejected_total{reason="busy_429"}`, and
197+
`jetty_threads_jobs`. These move before latency does, which CPU does not.
198+
* Failure, for alerting: `rate(tika_pipes_worker_restarts_total{reason=~"oom|timeout|crash"}[5m])`
199+
and `tika_server_rejected_total{reason="crash_503"}`. A `503` tells the client the
200+
document broke a fork; the restart counter tells you how often that is happening.
201+
* Latency: `histogram_quantile(0.95, sum by (le, endpoint) (rate(tika_server_requests_seconds_bucket[5m])))`.
202+
203+
== Not counted
204+
205+
An exception that no JAX-RS `ExceptionMapper` handles is answered by the servlet
206+
container's own error path, which bypasses the response filter that records
207+
`tika_server_requests_seconds`. Tika Server maps its own parse and pipes failures, so this
208+
only affects genuine server bugs.

tika-parent/pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@
402402
<log4j2.version>2.26.1</log4j2.version>
403403
<!-- can't update to 10, needs jdk21 -->
404404
<lucene.version>9.12.3</lucene.version>
405+
<micrometer.version>1.17.1</micrometer.version>
405406
<maven.plugin.annotations.version>3.15.2</maven.plugin.annotations.version>
406407
<metadata.extractor.version>2.21.0</metadata.extractor.version>
407408
<microsoft.translator.version>0.6.2</microsoft.translator.version>
@@ -593,6 +594,16 @@
593594
<artifactId>jetty-http2-server</artifactId>
594595
<version>${jetty.http2.version}</version>
595596
</dependency>
597+
<dependency>
598+
<groupId>io.micrometer</groupId>
599+
<artifactId>micrometer-core</artifactId>
600+
<version>${micrometer.version}</version>
601+
</dependency>
602+
<dependency>
603+
<groupId>io.micrometer</groupId>
604+
<artifactId>micrometer-registry-prometheus</artifactId>
605+
<version>${micrometer.version}</version>
606+
</dependency>
596607
<dependency>
597608
<groupId>org.jsoup</groupId>
598609
<artifactId>jsoup</artifactId>

tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ private static long totalMemorySize() {
180180
private volatile int port = -1;
181181
private long filesProcessed = 0;
182182
private volatile boolean pendingRestart = false;
183+
private final RestartCounter restarts = new RestartCounter();
183184
// Set once by shutdown()/close(); guards a request thread from starting a fresh
184185
// process after the manager has been torn down (which would leak the child).
185186
private volatile boolean closed = false;
@@ -281,6 +282,7 @@ public void incrementFilesProcessed(long maxFilesPerProcess) {
281282
if (filesProcessed >= maxFilesPerProcess) {
282283
LOG.info("clientId={}: reached max files limit ({}/{}), marking for restart",
283284
clientId, filesProcessed, maxFilesPerProcess);
285+
restarts.mark(RestartReason.MAX_FILES);
284286
pendingRestart = true;
285287
}
286288
}
@@ -292,18 +294,31 @@ public boolean needsRestart() {
292294

293295
@Override
294296
public void markServerForRestart() {
295-
LOG.info("clientId={}: marking server for restart", clientId);
297+
markServerForRestart(RestartReason.CRASH);
298+
}
299+
300+
@Override
301+
public void markServerForRestart(RestartReason reason) {
302+
LOG.info("clientId={}: marking server for restart ({})", clientId, reason);
303+
restarts.mark(reason);
296304
pendingRestart = true;
297305
}
298306

307+
@Override
308+
public long getRestartCount(RestartReason reason) {
309+
return restarts.count(reason);
310+
}
311+
299312
@Override
300313
public void connectionAbandoned() {
301314
LOG.info("clientId={}: connection abandoned, worker will be recycled on next use", clientId);
315+
restarts.mark(RestartReason.CONNECTION_ABANDONED);
302316
pendingRestart = true;
303317
}
304318

305319
@Override
306320
public int handleCrashAndGetExitCode() {
321+
restarts.mark(RestartReason.CRASH);
307322
pendingRestart = true;
308323
if (process != null) {
309324
try {
@@ -312,6 +327,8 @@ public int handleCrashAndGetExitCode() {
312327
int exitValue = process.exitValue();
313328
if (exitValue == 0) {
314329
LOG.info("clientId={}: process exited cleanly", clientId);
330+
} else if (exitValue == PipesServer.IDLE_EXIT_CODE) {
331+
LOG.info("clientId={}: process exited after idle timeout", clientId);
315332
} else {
316333
LOG.warn("clientId={}: process exited with code {}", clientId, exitValue);
317334
}
@@ -335,7 +352,11 @@ public synchronized void ensureRunning() throws IOException, InterruptedExceptio
335352
if (isRunning() && !pendingRestart) {
336353
return;
337354
}
355+
Process previous = process;
338356
startServer();
357+
if (previous != null) {
358+
restarts.restarted(exitCodeOrMinusOne(previous));
359+
}
339360
}
340361

341362
@Override
@@ -374,6 +395,7 @@ public Socket connect(int socketTimeoutMillis) throws IOException, ServerInitial
374395
// 1. pb.start() fails (can't launch process) - handled in startServer()
375396
// 2. Server explicitly reports bad config via protocol - handled in waitForStartup()
376397
// 3. Exhausted all retry attempts - handled in maybeInit()
398+
restarts.mark(RestartReason.CRASH);
377399
pendingRestart = true;
378400
throw new IOException(
379401
"Server process died before connecting (exit code " + exitValue + ") - will retry");
@@ -391,6 +413,10 @@ public Socket connect(int socketTimeoutMillis) throws IOException, ServerInitial
391413
}
392414
}
393415

416+
static int exitCodeOrMinusOne(Process p) {
417+
return p.isAlive() ? -1 : p.exitValue();
418+
}
419+
394420
private synchronized void startServer() throws IOException, InterruptedException, TimeoutException, ServerInitializationException {
395421
if (closed) {
396422
throw new IllegalStateException("PerClientServerManager is closed");

0 commit comments

Comments
 (0)