This is an automated email from the ASF dual-hosted git repository. tballison pushed a commit to branch TIKA-4839-metrics in repository https://gitbox.apache.org/repos/asf/tika.git
commit aaaa67795934f8ca23df0439d3c7e6f9d949c67d Author: tallison <[email protected]> AuthorDate: Mon Aug 24 13:52:50 2026 -0400 TIKA-4839 - add micrometer to tika-server --- CHANGES.txt | 18 ++ docs/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/using-tika/server/index.adoc | 11 +- .../ROOT/pages/using-tika/server/monitoring.adoc | 208 +++++++++++++++++++ tika-parent/pom.xml | 11 + .../tika/pipes/core/PerClientServerManager.java | 28 ++- .../org/apache/tika/pipes/core/PipesClient.java | 12 +- .../org/apache/tika/pipes/core/PipesParser.java | 18 ++ .../org/apache/tika/pipes/core/RestartCounter.java | 63 ++++++ .../org/apache/tika/pipes/core/RestartReason.java | 31 +++ .../org/apache/tika/pipes/core/ServerManager.java | 10 + .../tika/pipes/core/SharedServerManager.java | 19 +- .../tika/pipes/core/async/AsyncProcessor.java | 5 + .../apache/tika/pipes/core/server/PipesServer.java | 7 +- .../apache/tika/pipes/core/RestartCounterTest.java | 67 ++++++ tika-server/tika-server-core/pom.xml | 8 + .../org/apache/tika/server/core/TikaServerCli.java | 5 + .../apache/tika/server/core/TikaServerConfig.java | 33 +++ .../apache/tika/server/core/TikaServerProcess.java | 91 ++++++++- .../tika/server/core/metrics/MetricsConfig.java | 62 ++++++ .../tika/server/core/metrics/MetricsServer.java | 99 +++++++++ .../server/core/metrics/TikaMetricsFilter.java | 117 +++++++++++ .../server/core/metrics/TikaServerMetrics.java | 225 +++++++++++++++++++++ .../tika/server/core/resource/AsyncResource.java | 4 + .../tika/server/core/TikaServerConfigTest.java | 44 ++++ .../core/TikaServerMetricsIntegrationTest.java | 129 ++++++++++++ .../server/core/metrics/MetricsServerTest.java | 84 ++++++++ .../server/core/metrics/TikaMetricsFilterTest.java | 156 ++++++++++++++ .../configs/tika-config-server-metrics.json | 16 ++ .../src/main/appended-resources/META-INF/LICENSE | 10 + 30 files changed, 1581 insertions(+), 11 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index eea5ad329b..ff6e11239d 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,23 @@ Release 4.1.0 - unreleased + * tika-server can publish Prometheus metrics via Micrometer. Set + --metricsPort <port> (or server.metrics.port in the JSON config) and a + separate scrape listener serves GET /metrics on that port; unset, nothing + is created. Meters: tika_server_requests_seconds (endpoint/method/status + class, fixed 12-bucket histogram), request/response size summaries, + tika_server_rejected_total (busy_429, crash_503, payload_413), + tika_pipes_workers (busy/idle), tika_pipes_worker_restarts_total by + reason (oom, timeout, crash, max_files, idle, connection_abandoned), + tika_pipes_queue_depth, the parse port's Jetty thread pool, and the JVM + binders. The scrape listener binds to the server host by default + (server.metrics.host overrides). New public API in tika-pipes-core: + RestartReason, ServerManager.markServerForRestart(RestartReason), + PipesParser.getNumClients/getIdleClientCount/getRestartCount and + AsyncProcessor.getQueueDepth. A forked PipesServer that shuts down after + its idle socket timeout now exits with code 24 (PipesServer.IDLE_EXIT_CODE) + instead of 0, so the parent can tell an idle exit from a requested + shutdown; 0 remains the SHUT_DOWN reply (TIKA-4839). + * The Kafka pipes iterator no longer stops at the first empty poll. A newly subscribed consumer spends its first poll(s) joining the group and returns empty even when the topic has a backlog, so the iterator could enqueue zero diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index fe2b5105f7..fe609b2e9a 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -20,6 +20,7 @@ ** xref:using-tika/cli/index.adoc[Command Line] ** xref:using-tika/server/index.adoc[Tika Server] *** xref:using-tika/server/tls.adoc[TLS/SSL Configuration] +*** xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus] ** xref:using-tika/grpc/index.adoc[gRPC] ** xref:using-tika/docker.adoc[Running Tika in Docker] * xref:pipes/index.adoc[Pipes] diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc b/docs/modules/ROOT/pages/using-tika/server/index.adoc index a156fa187f..0a288dcbb5 100644 --- a/docs/modules/ROOT/pages/using-tika/server/index.adoc +++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc @@ -56,6 +56,9 @@ moment" apart from "this document broke a fork" and act accordingly: back off an their fetch rate, and autoscalers have a signal to scale on — none of which is possible against a server that can only get slower or fail blind. +The same signal is available as metrics -- busy workers, `429` rate, worker restarts by reason -- +when a metrics port is set; see xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus]. + The cost is real and lands on you: backpressure only reports the capacity you configured. `numClients` is now an operational obligation, sized against both your request volume and your 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. |`-i <id>`, `--id <id>` |Server ID, written to the startup log. Defaults to a random UUID. +|`--metricsPort <port>` +|Enable Prometheus metrics on this port. See +xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus]. + |`-?`, `--help` |Print the usage message. |=== -NOTE: `-h`, `-p` and `-i` override the JSON config. Everything else — `allowPipes`, +NOTE: `-h`, `-p`, `-i` and `--metricsPort` override the JSON config. Everything else — `allowPipes`, `allowPerRequestConfig`, CORS, TLS, timeouts — is JSON-only. == Endpoints @@ -574,5 +581,7 @@ explicitly with the combined total in mind). == Topics * xref:using-tika/server/tls.adoc[TLS/SSL Configuration] — TLS and mutual authentication +* xref:using-tika/server/monitoring.adoc[Monitoring with Prometheus] — metrics for +autoscaling and alerting * xref:migration-to-4x/migrating-tika-server-4x.adoc[Migrating Tika Server to 4.x] — breaking changes from 3.x diff --git a/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc b/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc new file mode 100644 index 0000000000..dfb1ae770a --- /dev/null +++ b/docs/modules/ROOT/pages/using-tika/server/monitoring.adoc @@ -0,0 +1,208 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + += Monitoring with Prometheus +:toc: +:toclevels: 3 + +Tika Server can publish operational metrics in the Prometheus text exposition format, +via https://micrometer.io[Micrometer]. The metrics are built for two questions an operator +of a parse fleet actually has: _is this server saturated_ (scale on that, not on CPU) and +_are its forked workers dying_ (alert on that). + +== Enabling + +Metrics are off by default. Setting a metrics port turns them on; nothing else is needed. + +[source,bash] +---- +java -jar tika-server-standard-X.Y.Z.jar --metricsPort 9404 +---- + +or in `tika-config.json`: + +[source,json] +---- +{ + "server": { + "port": 9998, + "metrics": { + "port": 9404, + "host": "127.0.0.1", + "commonTags": { "cluster": "blue" } + } + } +} +---- + +[cols="1,3"] +|=== +|Key |Meaning + +|`port` +|The scrape port. Unset means metrics are entirely off: no registry, no meters, no +listener. `--metricsPort` on the command line overrides the JSON value. Must differ from +the server port. + +|`host` +|Bind address for the scrape listener. Defaults to the server's own host (`-h`), so a +server started with `-h 0.0.0.0` in a container exposes its metrics on the pod IP with no +extra configuration. Set this to `127.0.0.1` to restrict scraping to a sidecar. + +|`commonTags` +|Labels added to every series, for example a cluster or tier name. +|=== + +Metrics are served on a *separate port* from the parse endpoints, on purpose. The parse +port receives untrusted documents; the metrics port should be reachable only by your +scraper. Keep them apart at the network layer (a Kubernetes `NetworkPolicy`, a security +group). The scrape listener answers only `GET /metrics` -- every parse endpoint is `404` +there, and `/metrics` is `404` on the parse port. It also has its own small thread pool, +so a scrape never waits behind a slow parse. + +The scrape listener is plain HTTP even when the parse port uses +xref:using-tika/server/tls.adoc[TLS]. + +== Probes + +Point Kubernetes liveness and readiness probes at the parse port (for example +`GET /version`, or `GET /status` when that endpoint is enabled), never at `/metrics`. A +scrape target is not a health check: the listener stays up while the parser is failing, +and a metrics misconfiguration must not take a healthy parser out of rotation. + +== Scrape configuration + +[source,yaml] +---- +scrape_configs: + - job_name: tika-server + static_configs: + - targets: ['tika-1:9404', 'tika-2:9404'] +---- + +For the Prometheus Operator, a `ServiceMonitor` selecting a Service that exposes the +metrics port works the same way. + +== Meters + +Durations are Micrometer timers exported in seconds with a fixed histogram of twelve +buckets (10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 30s, 60s, 120s), so +`histogram_quantile` works and the series count per pod stays small. Every label value is +drawn from a fixed set -- endpoint names, status classes, enum names -- never from the +request, so cardinality cannot grow with traffic. + +=== HTTP (parse port) + +[cols="2,1,2,3"] +|=== +|Meter |Type |Labels |Meaning + +|`tika_server_requests_seconds` +|timer +|`endpoint`, `method`, `status` +|Every request on the parse port, timed from before routing to the response. `endpoint` +is the first path segment when it is one of the server's endpoints (`tika`, `rmeta`, +`meta`, `unpack`, `detect`, `language`, `mime-types`, `detectors`, `parsers`, `version`, +`status`, `pipes`, `async`), `other` for any other resource, `unmatched` for a `404` +that matched nothing. `status` is the class: `2xx`, `4xx`, `5xx`. + +|`tika_server_request_size_bytes` +|summary +|`endpoint` +|Request body size from `Content-Length`; chunked uploads have none and are not recorded. + +|`tika_server_response_size_bytes` +|summary +|`endpoint` +|Bytes of the response entity, before any compression. + +|`tika_server_rejected_total` +|counter +|`reason` +|Requests refused for capacity reasons, by the status the server already uses to encode +them: `busy_429` (the `/async` queue was full, or no fork was free within +`maxWaitForClientMillis`), `crash_503` (the fork serving the request OOM'd, timed out or +crashed), `payload_413` (body over `maxRequestSizeBytes` or the IPC payload limit). + +|`tika_server_tasks_active` +|gauge +| +|Parse/detect tasks in flight right now. + +|`tika_server_tasks_started_total` +|counter +| +|Tasks started since the server came up. + +|`jetty_threads_*` +|gauges +| +|The parse port's request thread pool. `jetty_threads_jobs` (queued jobs) is HTTP-level +backpressure: requests waiting for a thread. +|=== + +=== Forked workers + +[cols="2,1,2,3"] +|=== +|Meter |Type |Labels |Meaning + +|`tika_pipes_workers` +|gauge +|`state` +|Forked pipes workers `busy` and `idle`. `busy / (busy + idle)` sustained near 1 means the +server is at capacity; see xref:pipes/cpu-sizing.adoc[Forked-JVM CPU and Heap Sizing] +before raising `numClients`. + +|`tika_pipes_worker_restarts_total` +|counter +|`reason` +|Forked workers restarted, by why: `oom`, `timeout`, `crash` (an unexplained exit), +`max_files` (routine recycling after `maxFilesProcessedPerProcess`), `idle` (the worker +shut itself down after `socketTimeoutMillis` without a request -- exit code 24 -- and was +started again on the next one), `connection_abandoned` (a request was interrupted mid-flight). Alert on +`oom`, `timeout` and `crash`; `max_files` and `idle` are expected. + +|`tika_pipes_queue_depth` +|gauge +| +|Tuples accepted by `/async` and not yet picked up by a worker. Only present when the +`async` endpoint is enabled. +|=== + +=== JVM and process + +The standard Micrometer binders are enabled: `jvm_memory_*`, `jvm_gc_*`, `jvm_threads_*`, +`process_cpu_usage`, `system_cpu_usage`, `process_files_*`, `process_uptime_seconds`. +These describe the server JVM only, not the forked workers. + +== What to scale and alert on + +* Saturation, for an autoscaler: `tika_pipes_workers{state="busy"}` as a ratio of the + total, the rate of `tika_server_rejected_total{reason="busy_429"}`, and + `jetty_threads_jobs`. These move before latency does, which CPU does not. +* Failure, for alerting: `rate(tika_pipes_worker_restarts_total{reason=~"oom|timeout|crash"}[5m])` + and `tika_server_rejected_total{reason="crash_503"}`. A `503` tells the client the + document broke a fork; the restart counter tells you how often that is happening. +* Latency: `histogram_quantile(0.95, sum by (le, endpoint) (rate(tika_server_requests_seconds_bucket[5m])))`. + +== Not counted + +An exception that no JAX-RS `ExceptionMapper` handles is answered by the servlet +container's own error path, which bypasses the response filter that records +`tika_server_requests_seconds`. Tika Server maps its own parse and pipes failures, so this +only affects genuine server bugs. diff --git a/tika-parent/pom.xml b/tika-parent/pom.xml index c19d259e3c..c01fca2500 100644 --- a/tika-parent/pom.xml +++ b/tika-parent/pom.xml @@ -402,6 +402,7 @@ <log4j2.version>2.26.1</log4j2.version> <!-- can't update to 10, needs jdk21 --> <lucene.version>9.12.3</lucene.version> + <micrometer.version>1.17.1</micrometer.version> <maven.plugin.annotations.version>3.15.2</maven.plugin.annotations.version> <metadata.extractor.version>2.21.0</metadata.extractor.version> <microsoft.translator.version>0.6.2</microsoft.translator.version> @@ -593,6 +594,16 @@ <artifactId>jetty-http2-server</artifactId> <version>${jetty.http2.version}</version> </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-core</artifactId> + <version>${micrometer.version}</version> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-registry-prometheus</artifactId> + <version>${micrometer.version}</version> + </dependency> <dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java index e5e6cc83eb..56900dceef 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PerClientServerManager.java @@ -180,6 +180,7 @@ public class PerClientServerManager implements ServerManager { private volatile int port = -1; private long filesProcessed = 0; private volatile boolean pendingRestart = false; + private final RestartCounter restarts = new RestartCounter(); // Set once by shutdown()/close(); guards a request thread from starting a fresh // process after the manager has been torn down (which would leak the child). private volatile boolean closed = false; @@ -281,6 +282,7 @@ public class PerClientServerManager implements ServerManager { if (filesProcessed >= maxFilesPerProcess) { LOG.info("clientId={}: reached max files limit ({}/{}), marking for restart", clientId, filesProcessed, maxFilesPerProcess); + restarts.mark(RestartReason.MAX_FILES); pendingRestart = true; } } @@ -292,18 +294,31 @@ public class PerClientServerManager implements ServerManager { @Override public void markServerForRestart() { - LOG.info("clientId={}: marking server for restart", clientId); + markServerForRestart(RestartReason.CRASH); + } + + @Override + public void markServerForRestart(RestartReason reason) { + LOG.info("clientId={}: marking server for restart ({})", clientId, reason); + restarts.mark(reason); pendingRestart = true; } + @Override + public long getRestartCount(RestartReason reason) { + return restarts.count(reason); + } + @Override public void connectionAbandoned() { LOG.info("clientId={}: connection abandoned, worker will be recycled on next use", clientId); + restarts.mark(RestartReason.CONNECTION_ABANDONED); pendingRestart = true; } @Override public int handleCrashAndGetExitCode() { + restarts.mark(RestartReason.CRASH); pendingRestart = true; if (process != null) { try { @@ -312,6 +327,8 @@ public class PerClientServerManager implements ServerManager { int exitValue = process.exitValue(); if (exitValue == 0) { LOG.info("clientId={}: process exited cleanly", clientId); + } else if (exitValue == PipesServer.IDLE_EXIT_CODE) { + LOG.info("clientId={}: process exited after idle timeout", clientId); } else { LOG.warn("clientId={}: process exited with code {}", clientId, exitValue); } @@ -335,7 +352,11 @@ public class PerClientServerManager implements ServerManager { if (isRunning() && !pendingRestart) { return; } + Process previous = process; startServer(); + if (previous != null) { + restarts.restarted(exitCodeOrMinusOne(previous)); + } } @Override @@ -374,6 +395,7 @@ public class PerClientServerManager implements ServerManager { // 1. pb.start() fails (can't launch process) - handled in startServer() // 2. Server explicitly reports bad config via protocol - handled in waitForStartup() // 3. Exhausted all retry attempts - handled in maybeInit() + restarts.mark(RestartReason.CRASH); pendingRestart = true; throw new IOException( "Server process died before connecting (exit code " + exitValue + ") - will retry"); @@ -391,6 +413,10 @@ public class PerClientServerManager implements ServerManager { } } + static int exitCodeOrMinusOne(Process p) { + return p.isAlive() ? -1 : p.exitValue(); + } + private synchronized void startServer() throws IOException, InterruptedException, TimeoutException, ServerInitializationException { if (closed) { throw new IllegalStateException("PerClientServerManager is closed"); diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java index 7b2b34a974..34cea6cf44 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesClient.java @@ -404,7 +404,7 @@ public class PipesClient implements Closeable { LOG.warn("clientId={}: client-side backstop timeout: id={} elapsed={}ms limit={}ms " + "-- server should have self-terminated well before this", pipesClientId, t.getId(), totalElapsed, clientBackstopMillis); - serverManager.markServerForRestart(); + serverManager.markServerForRestart(RestartReason.TIMEOUT); closeConnection(); return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT, intermediateResult.get()); } @@ -420,19 +420,19 @@ public class PipesClient implements Closeable { switch (msg.type()) { case OOM: String oomMsg = JsonPipesIpc.fromBytes(msg.payload(), String.class); - serverManager.markServerForRestart(); + serverManager.markServerForRestart(RestartReason.OOM); closeConnection(); return buildFatalResult(t.getId(), t.getEmitKey(), PipesResult.RESULT_STATUS.OOM, intermediateResult.get(), oomMsg); case TIMEOUT: String timeoutMsg = JsonPipesIpc.fromBytes(msg.payload(), String.class); - serverManager.markServerForRestart(); + serverManager.markServerForRestart(RestartReason.TIMEOUT); closeConnection(); return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT, intermediateResult.get(), timeoutMsg); case UNSPECIFIED_CRASH: String crashMsg = JsonPipesIpc.fromBytes(msg.payload(), String.class); - serverManager.markServerForRestart(); + serverManager.markServerForRestart(RestartReason.CRASH); closeConnection(); return buildFatalResult(t.getId(), t.getEmitKey(), UNSPECIFIED_CRASH, intermediateResult.get(), crashMsg); @@ -466,7 +466,7 @@ public class PipesClient implements Closeable { } catch (SocketTimeoutException e) { LOG.warn("clientId={}: Socket timeout exception while waiting for server", pipesClientId, e); // Mark for restart - server is stuck on current request and needs to be restarted - serverManager.markServerForRestart(); + serverManager.markServerForRestart(RestartReason.TIMEOUT); closeConnection(); return buildFatalResult(t.getId(), t.getEmitKey(), TIMEOUT, intermediateResult.get(), ExceptionUtils.getStackTrace(e)); @@ -486,8 +486,10 @@ public class PipesClient implements Closeable { PipesResult.RESULT_STATUS status = UNSPECIFIED_CRASH; if (exitCode == PipesMessageType.OOM.getExitCode().orElse(-1)) { status = PipesResult.RESULT_STATUS.OOM; + serverManager.markServerForRestart(RestartReason.OOM); } else if (exitCode == PipesMessageType.TIMEOUT.getExitCode().orElse(-1)) { status = PipesResult.RESULT_STATUS.TIMEOUT; + serverManager.markServerForRestart(RestartReason.TIMEOUT); } closeConnection(); return buildFatalResult(t.getId(), t.getEmitKey(), status, intermediateResult.get(), diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java index dd68dca002..89b527b243 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/PipesParser.java @@ -168,6 +168,24 @@ public class PipesParser implements Closeable { return serverManagers.stream().filter(ServerManager::isRunning).count(); } + public int getNumClients() { + return pipesConfig.getNumClients(); + } + + /** Clients not currently borrowed by a {@link #parse} call. */ + public int getIdleClientCount() { + return clientQueue.size(); + } + + /** Forked-server restarts performed so far for {@code reason}, summed over all servers. */ + public long getRestartCount(RestartReason reason) { + long total = 0; + for (ServerManager m : serverManagers) { + total += m.getRestartCount(reason); + } + return total; + } + /** * Returns whether this parser is using shared server mode. * diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartCounter.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartCounter.java new file mode 100644 index 0000000000..141f84a97e --- /dev/null +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartCounter.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.pipes.core; + +import java.util.EnumMap; +import java.util.concurrent.atomic.LongAdder; + +import org.apache.tika.pipes.core.server.PipesServer; + +/** + * Per-manager restart bookkeeping: the reason recorded while a restart is pending, and + * monotonic counts of restarts actually performed, by reason. + * <p> + * Last recorded reason wins: a crash is first seen generically and then refined + * (OOM/TIMEOUT) from the exit code, so the refinement must overwrite. + */ +final class RestartCounter { + + private final EnumMap<RestartReason, LongAdder> counts = new EnumMap<>(RestartReason.class); + private volatile RestartReason pending; + + RestartCounter() { + for (RestartReason r : RestartReason.values()) { + counts.put(r, new LongAdder()); + } + } + + void mark(RestartReason reason) { + pending = reason; + } + + /** + * Records that a restart happened. With no reason marked, the worker died on its own: + * the idle exit code is the idle shutdown, anything else -- including an unexplained + * 0 -- is a crash. + */ + void restarted(int exitCode) { + RestartReason reason = pending; + pending = null; + if (reason == null) { + reason = exitCode == PipesServer.IDLE_EXIT_CODE ? RestartReason.IDLE : RestartReason.CRASH; + } + counts.get(reason).increment(); + } + + long count(RestartReason reason) { + return counts.get(reason).sum(); + } +} diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartReason.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartReason.java new file mode 100644 index 0000000000..994667a5f3 --- /dev/null +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/RestartReason.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.pipes.core; + +/** + * Why a forked pipes server was (or will be) restarted. Bounded on purpose: these + * become metric tag values. + */ +public enum RestartReason { + OOM, + TIMEOUT, + CRASH, + MAX_FILES, + CONNECTION_ABANDONED, + /** The worker exited cleanly on its own after sitting idle (socket timeout). */ + IDLE +} diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java index 3a663193e6..e890e03d15 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/ServerManager.java @@ -110,6 +110,16 @@ public interface ServerManager extends Closeable { // Default no-op for backward compatibility } + /** As {@link #markServerForRestart()}, attributing the restart to {@code reason}. */ + default void markServerForRestart(RestartReason reason) { + markServerForRestart(); + } + + /** Restarts performed so far for {@code reason}; monotonic, never reset. */ + default long getRestartCount(RestartReason reason) { + return 0; + } + /** * Signals that the client walked away from its connection, whether * mid-handshake or with a request still in flight. diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java index ea6db80039..a62e7018c9 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/SharedServerManager.java @@ -77,6 +77,7 @@ public class SharedServerManager implements ServerManager { private final AtomicLong filesProcessed = new AtomicLong(0); private volatile boolean restarting = false; private volatile boolean pendingRestart = false; // Set when a client reports fatal error or max files reached + private final RestartCounter restarts = new RestartCounter(); private volatile byte[] currentToken; private Process process; private Path tmpDir; @@ -142,10 +143,14 @@ public class SharedServerManager implements ServerManager { return; } restarting = true; + Process previous = process; try { startServer(); pendingRestart = false; // Clear the flag after successful restart filesProcessed.set(0); // Reset file counter on restart + if (previous != null) { + restarts.restarted(PerClientServerManager.exitCodeOrMinusOne(previous)); + } } finally { restarting = false; lock.notifyAll(); // Wake up any threads waiting in getPort() @@ -165,12 +170,23 @@ public class SharedServerManager implements ServerManager { */ @Override public void markServerForRestart() { + markServerForRestart(RestartReason.CRASH); + } + + @Override + public void markServerForRestart(RestartReason reason) { synchronized (lock) { - LOG.debug("Server marked for restart - will restart on next ensureRunning()"); + LOG.debug("Server marked for restart ({}) - will restart on next ensureRunning()", reason); + restarts.mark(reason); pendingRestart = true; } } + @Override + public long getRestartCount(RestartReason reason) { + return restarts.count(reason); + } + /** * Increments the count of files processed and marks for restart if limit reached. */ @@ -184,6 +200,7 @@ public class SharedServerManager implements ServerManager { synchronized (lock) { LOG.info("Shared server reached max files limit ({}/{}), marking for restart", count, maxFilesPerProcess); + restarts.mark(RestartReason.MAX_FILES); pendingRestart = true; } } diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java index be61563d12..5c02398fcb 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/async/AsyncProcessor.java @@ -247,6 +247,11 @@ public class AsyncProcessor implements Closeable { return fetchEmitTuples.remainingCapacity(); } + /** Tuples accepted but not yet picked up by a worker. */ + public int getQueueDepth() { + return fetchEmitTuples.size(); + } + /** * Long-running callers (tika-server) set true so one bad tuple * (unknown fetcher, init failure) can't halt the shared workers; diff --git a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java index ee14e103f5..0c3e247641 100644 --- a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java +++ b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java @@ -148,6 +148,11 @@ public class PipesServer implements AutoCloseable { * tell the difference between "I crashed" and "my parent went away". */ public static final int PARENT_GONE_EXIT_CODE = 23; + /** Exit code for the idle shutdown (no request within socketTimeoutMillis). Not 0: + * 0 is reserved for a parent-requested SHUT_DOWN, so the parent can tell a worker + * that timed out from one that stopped for any other reason. */ + public static final int IDLE_EXIT_CODE = 24; + private final long heartbeatIntervalMillis; private final String pipesClientId; @@ -402,7 +407,7 @@ public class PipesServer implements AutoCloseable { } catch (Exception ex) { //swallow } - System.exit(0); + System.exit(IDLE_EXIT_CODE); return; // unreachable, but needed for compilation } LOG.trace("received message type={}", msg.type()); diff --git a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/RestartCounterTest.java b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/RestartCounterTest.java new file mode 100644 index 0000000000..d19e47bc53 --- /dev/null +++ b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/RestartCounterTest.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.pipes.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import org.apache.tika.pipes.core.server.PipesServer; + +public class RestartCounterTest { + + @Test + public void testMarkedReasonWins() { + RestartCounter c = new RestartCounter(); + c.mark(RestartReason.MAX_FILES); + c.restarted(PipesServer.IDLE_EXIT_CODE); + assertEquals(1, c.count(RestartReason.MAX_FILES)); + assertEquals(0, c.count(RestartReason.IDLE)); + } + + @Test + public void testLastMarkWins() { + // generic crash first, then refined from the exit code + RestartCounter c = new RestartCounter(); + c.mark(RestartReason.CRASH); + c.mark(RestartReason.OOM); + c.restarted(-1); + assertEquals(1, c.count(RestartReason.OOM)); + assertEquals(0, c.count(RestartReason.CRASH)); + } + + @Test + public void testUnmarkedAttributedByExitCode() { + RestartCounter c = new RestartCounter(); + c.restarted(PipesServer.IDLE_EXIT_CODE); + c.restarted(0); // unexplained clean exit: the parent did not ask for it + c.restarted(1); + c.restarted(-1); + assertEquals(1, c.count(RestartReason.IDLE)); + assertEquals(3, c.count(RestartReason.CRASH)); + } + + @Test + public void testPendingClearsAfterRestart() { + RestartCounter c = new RestartCounter(); + c.mark(RestartReason.TIMEOUT); + c.restarted(-1); + c.restarted(PipesServer.IDLE_EXIT_CODE); + assertEquals(1, c.count(RestartReason.TIMEOUT)); + assertEquals(1, c.count(RestartReason.IDLE)); + } +} diff --git a/tika-server/tika-server-core/pom.xml b/tika-server/tika-server-core/pom.xml index 90de66d67d..dee2f69761 100644 --- a/tika-server/tika-server-core/pom.xml +++ b/tika-server/tika-server-core/pom.xml @@ -88,6 +88,14 @@ <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-core</artifactId> + </dependency> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-registry-prometheus</artifactId> + </dependency> <dependency> <groupId>org.apache.cxf</groupId> <artifactId>cxf-rt-rs-security-cors</artifactId> diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java index 6e5da93fd5..57590d41b8 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java @@ -28,6 +28,8 @@ import org.apache.commons.cli.help.HelpFormatter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.tika.server.core.metrics.MetricsConfig; + public class TikaServerCli { private static final Logger LOG = LoggerFactory.getLogger(TikaServerCli.class); @@ -39,6 +41,9 @@ public class TikaServerCli { options.addOption("?", "help", false, "this help message"); options.addOption("c", "config", true, "tika-config file"); options.addOption("i", "id", true, "id for this server, written to the startup log"); + options.addOption(null, "metricsPort", true, + "enable Prometheus metrics on this port (default when enabled in config = " + + MetricsConfig.DEFAULT_PORT + ")"); return options; } diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java index eb8912eb85..033285707e 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java @@ -33,6 +33,7 @@ import org.slf4j.LoggerFactory; import org.apache.tika.config.loader.TikaLoader; import org.apache.tika.exception.TikaConfigException; import org.apache.tika.exception.TikaException; +import org.apache.tika.server.core.metrics.MetricsConfig; public class TikaServerConfig { @@ -74,6 +75,7 @@ public class TikaServerConfig { private ArrayList<String> endpoints = new ArrayList<>(); private TlsConfig tlsConfig = new TlsConfig(); + private MetricsConfig metrics = new MetricsConfig(); /** * Config with only the defaults @@ -109,6 +111,11 @@ public class TikaServerConfig { settings.add("id"); } + if (commandLine.hasOption("metricsPort")) { + config.getMetrics().setPort(Integer.parseInt(commandLine.getOptionValue("metricsPort"))); + settings.add("metricsPort"); + } + config.validateConsistency(settings); return config; } @@ -180,6 +187,16 @@ public class TikaServerConfig { } } tlsConfig.checkInitialization(); + if (metrics.isEnabled()) { + int metricsPort = metrics.getPort(); + if (metricsPort < 0 || metricsPort > 65535) { + throw new TikaConfigException("metrics.port must be 0-65535, got " + metricsPort); + } + if (metricsPort != 0 && metricsPort == port) { + throw new TikaConfigException("metrics.port (" + metricsPort + + ") must differ from the server port"); + } + } } public String getHost() { @@ -269,6 +286,22 @@ public class TikaServerConfig { this.tlsConfig = tlsConfig; } + /** + * Prometheus metrics; off unless {@code metrics.port} (or {@code --metricsPort}) is set. + */ + public MetricsConfig getMetrics() { + return metrics; + } + + public void setMetrics(MetricsConfig metrics) { + this.metrics = metrics == null ? new MetricsConfig() : metrics; + } + + /** Bind address for the scrape listener: {@code metrics.host} if set, else the server host. */ + public String getMetricsHost() { + return metrics.getHost() == null ? host : metrics.getHost(); + } + public ArrayList<String> getEndpoints() { return endpoints; } diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java index 0e99ec7c71..65126c1a50 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java @@ -52,6 +52,7 @@ import org.apache.cxf.rs.security.cors.CrossOriginResourceSharingFilter; import org.apache.cxf.service.factory.ServiceConstructionException; import org.apache.cxf.transport.common.gzip.GZIPInInterceptor; import org.apache.cxf.transport.common.gzip.GZIPOutInterceptor; +import org.apache.cxf.transport.http_jetty.JettyHTTPServerEngine; import org.apache.cxf.transport.http_jetty.JettyHTTPServerEngineFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,6 +71,9 @@ import org.apache.tika.pipes.core.PipesConfig; import org.apache.tika.pipes.core.PipesParser; import org.apache.tika.pipes.core.config.ConfigMerger; import org.apache.tika.pipes.core.config.ConfigOverrides; +import org.apache.tika.server.core.metrics.MetricsServer; +import org.apache.tika.server.core.metrics.TikaMetricsFilter; +import org.apache.tika.server.core.metrics.TikaServerMetrics; import org.apache.tika.server.core.resource.AsyncResource; import org.apache.tika.server.core.resource.DetectorResource; import org.apache.tika.server.core.resource.LanguageResource; @@ -103,7 +107,7 @@ public class TikaServerProcess { private static final Logger LOG = LoggerFactory.getLogger(TikaServerProcess.class); public static int DO_NOT_RESTART_EXIT_VALUE = -100; - private static final List<String> VALID_ENDPOINTS = List.of("tika", "rmeta", "meta", + public static final List<String> VALID_ENDPOINTS = List.of("tika", "rmeta", "meta", "unpack", "detect", "language", "mime", "mime-types", "detectors", "parsers", "version", "status", "pipes", "async"); @@ -118,6 +122,7 @@ public class TikaServerProcess { options.addOption("p", "port", true, "listen port"); options.addOption("c", "config", true, "Tika Configuration xml file to override default config with."); options.addOption("i", "id", true, "id for this server, written to the startup log"); + options.addOption(null, "metricsPort", true, "enable Prometheus metrics on this port"); options.addOption("?", "help", false, "this help message"); return options; } @@ -168,6 +173,42 @@ public class TikaServerProcess { System.exit(DO_NOT_RESTART_EXIT_VALUE); } LOG.info("Started Apache Tika server {} at {}", serverDetails.serverId, serverDetails.url); + if (serverDetails.metrics != null) { + bindJettyThreadPool(serverDetails); + startMetricsServer(serverDetails); + } + } + + // Read-only: the CXF-owned Jetty engine is only consulted for its request pool. + private static void bindJettyThreadPool(ServerDetails serverDetails) { + try { + JettyHTTPServerEngineFactory factory = serverDetails.sf.getBus() + .getExtension(JettyHTTPServerEngineFactory.class); + JettyHTTPServerEngine engine = factory == null ? null : + factory.retrieveJettyHTTPServerEngine(serverDetails.port); + if (engine == null || engine.getServer() == null) { + LOG.warn("metrics: CXF Jetty engine not found for port {}; skipping request " + + "thread pool metrics", serverDetails.port); + return; + } + serverDetails.metrics.bindJettyThreadPool(engine.getServer().getThreadPool()); + } catch (Exception e) { + LOG.warn("metrics: could not bind request thread pool metrics", e); + } + } + + private static void startMetricsServer(ServerDetails serverDetails) { + try { + serverDetails.metricsServer.start(); + } catch (Exception e) { + LOG.warn("exception starting metrics server", e); + if (isBindException(e)) { + System.exit(BIND_EXCEPTION); + } + System.exit(DO_NOT_RESTART_EXIT_VALUE); + } + LOG.info("Started metrics listener on {}:{}{}", serverDetails.metricsHost, + serverDetails.metricsServer.getPort(), MetricsServer.PATH); } /** @@ -185,9 +226,19 @@ public class TikaServerProcess { LOG.warn("Error stopping HTTP server", e); } } + if (serverDetails.metricsServer != null) { + try { + serverDetails.metricsServer.close(); + } catch (Exception e) { + LOG.warn("Error stopping metrics server", e); + } + } if (serverDetails.pipesParsingHelper != null) { serverDetails.pipesParsingHelper.shutdown(); } + if (serverDetails.metrics != null) { + serverDetails.metrics.close(); + } })); } @@ -249,6 +300,24 @@ public class TikaServerProcess { List<Object> providers = new ArrayList<>(); loadAllProviders(tikaServerConfig, serverStatus, tikaResource, resourceProviders, providers); + TikaServerMetrics metrics = null; + MetricsServer metricsServer = null; + if (tikaServerConfig.getMetrics().isEnabled()) { + metrics = new TikaServerMetrics(tikaServerConfig.getMetrics()); + metrics.bindJvm(); + metrics.bindServerStatus(serverStatus); + if (pipesParsingHelper != null) { + metrics.bindPipes(pipesParsingHelper.getPipesParser()); + } + AsyncResource asyncResource = findAsyncResource(resourceProviders); + if (asyncResource != null) { + metrics.bindAsyncQueue(asyncResource); + } + providers.add(new TikaMetricsFilter(metrics)); + metricsServer = new MetricsServer(tikaServerConfig.getMetricsHost(), + tikaServerConfig.getMetrics().getPort(), metrics); + } + sf.setResourceProviders(resourceProviders); sf.setProviders(providers); @@ -290,9 +359,23 @@ public class TikaServerProcess { details.serverId = tikaServerConfig.getId(); details.serverStatus = serverStatus; details.pipesParsingHelper = pipesParsingHelper; + details.port = port; + details.metrics = metrics; + details.metricsServer = metricsServer; + details.metricsHost = tikaServerConfig.getMetricsHost(); return details; } + private static AsyncResource findAsyncResource(List<ResourceProvider> resourceProviders) { + for (ResourceProvider p : resourceProviders) { + if (p instanceof SingletonResourceProvider s + && s.getInstance(null) instanceof AsyncResource asyncResource) { + return asyncResource; + } + } + return null; + } + private static TLSServerParameters getTlsParams(TlsConfig tlsConfig) throws GeneralSecurityException, IOException, TikaConfigException { // Also checked in TlsConfig.checkInitialization() at config-load time; kept here too @@ -551,7 +634,7 @@ public class TikaServerProcess { * Root segment of the class-level {@code @Path}, walking up the hierarchy: * {@code @Path} is not {@code @Inherited}, but JAX-RS resolves it from superclasses. */ - static String resourcePathRoot(Class<?> resourceClass) { + public static String resourcePathRoot(Class<?> resourceClass) { for (Class<?> c = resourceClass; c != null && c != Object.class; c = c.getSuperclass()) { jakarta.ws.rs.Path path = c.getAnnotation(jakarta.ws.rs.Path.class); if (path != null) { @@ -753,5 +836,9 @@ public class TikaServerProcess { String url; ServerStatus serverStatus; PipesParsingHelper pipesParsingHelper; + int port; + TikaServerMetrics metrics; + MetricsServer metricsServer; + String metricsHost; } } diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsConfig.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsConfig.java new file mode 100644 index 0000000000..25b773760c --- /dev/null +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsConfig.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.metrics; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The {@code metrics} block of the tika-server config. The port is the only switch: + * unset means no registry, no meters, no scrape listener. + */ +public class MetricsConfig { + + public static final int DEFAULT_PORT = 9404; + + private Integer port; + private String host; + private Map<String, String> commonTags = new LinkedHashMap<>(); + + public boolean isEnabled() { + return port != null; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + + /** Bind address for the scrape listener; null follows the server's own host. */ + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public Map<String, String> getCommonTags() { + return commonTags; + } + + public void setCommonTags(Map<String, String> commonTags) { + this.commonTags = commonTags == null ? new LinkedHashMap<>() : commonTags; + } +} diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsServer.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsServer.java new file mode 100644 index 0000000000..2436592437 --- /dev/null +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/MetricsServer.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.metrics; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.thread.QueuedThreadPool; + +/** + * The scrape listener: a separate, minimal Jetty server that answers {@code GET /metrics} + * and nothing else. Deliberately not a connector on the CXF-owned server, so the parse + * endpoints are unreachable on this port by construction and a slow parse can never + * queue a scrape -- it has its own (tiny) thread pool. + */ +public final class MetricsServer implements AutoCloseable { + + public static final String PATH = "/metrics"; + static final String CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"; + private static final int MAX_THREADS = 4; + + private final Server server; + private final ServerConnector connector; + + public MetricsServer(String host, int port, TikaServerMetrics metrics) { + QueuedThreadPool pool = new QueuedThreadPool(MAX_THREADS, 1); + pool.setName("tika-metrics"); + server = new Server(pool); + connector = new ServerConnector(server); + connector.setHost(host); + connector.setPort(port); + server.addConnector(connector); + server.setHandler(new ScrapeHandler(metrics)); + } + + public void start() throws Exception { + server.start(); + } + + /** The bound port; meaningful after {@link #start()} (port 0 picks a free one). */ + public int getPort() { + return connector.getLocalPort(); + } + + @Override + public void close() throws Exception { + server.stop(); + } + + private static final class ScrapeHandler extends Handler.Abstract { + + private final TikaServerMetrics metrics; + + private ScrapeHandler(TikaServerMetrics metrics) { + this.metrics = metrics; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + if (!PATH.equals(Request.getPathInContext(request))) { + Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404); + return true; + } + if (!HttpMethod.GET.is(request.getMethod())) { + Response.writeError(request, response, callback, HttpStatus.METHOD_NOT_ALLOWED_405); + return true; + } + byte[] body = metrics.scrape().getBytes(StandardCharsets.UTF_8); + response.setStatus(HttpStatus.OK_200); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, CONTENT_TYPE); + response.getHeaders().put(HttpHeader.CONTENT_LENGTH, body.length); + response.write(true, ByteBuffer.wrap(body), callback); + return true; + } + } +} diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaMetricsFilter.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaMetricsFilter.java new file mode 100644 index 0000000000..e04ce7c673 --- /dev/null +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaMetricsFilter.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.metrics; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; +import java.util.Set; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.ext.Provider; +import jakarta.ws.rs.ext.WriterInterceptor; +import jakarta.ws.rs.ext.WriterInterceptorContext; +import org.apache.commons.io.output.CountingOutputStream; + +import org.apache.tika.server.core.TikaServerProcess; + +/** + * Records {@code tika_server_requests} and the size summaries for every request on the + * parse-side listener. + * <p> + * Timing starts pre-matching so unmatched (404) and aborted (413) requests are counted + * too. The endpoint tag is the first path segment when it names one of the server's + * endpoints; any other matched resource is {@code other} and an unmatched request is + * {@code unmatched}. Response bytes are counted by wrapping the entity stream, since a + * response filter runs before a streamed body is written. + * <p> + * Not counted: an exception no {@code ExceptionMapper} handles. CXF answers those from + * its fault chain, which does not run response filters. + */ +@Provider +@PreMatching +public class TikaMetricsFilter implements ContainerRequestFilter, ContainerResponseFilter, + WriterInterceptor { + + static final String START_NANOS = TikaMetricsFilter.class.getName() + ".start"; + static final String ENDPOINT = TikaMetricsFilter.class.getName() + ".endpoint"; + static final String UNMATCHED = "unmatched"; + static final String OTHER = "other"; + + private final TikaServerMetrics metrics; + private final Set<String> knownEndpoints; + + public TikaMetricsFilter(TikaServerMetrics metrics) { + this(metrics, Set.copyOf(TikaServerProcess.VALID_ENDPOINTS)); + } + + TikaMetricsFilter(TikaServerMetrics metrics, Set<String> knownEndpoints) { + this.metrics = metrics; + this.knownEndpoints = knownEndpoints; + } + + @Override + public void filter(ContainerRequestContext requestContext) { + requestContext.setProperty(START_NANOS, System.nanoTime()); + } + + @Override + public void filter(ContainerRequestContext requestContext, + ContainerResponseContext responseContext) { + String endpoint = endpoint(requestContext); + requestContext.setProperty(ENDPOINT, endpoint); + Object start = requestContext.getProperty(START_NANOS); + long nanos = start instanceof Long s ? System.nanoTime() - s : 0L; + metrics.recordRequest(endpoint, requestContext.getMethod(), responseContext.getStatus(), + nanos); + int length = requestContext.getLength(); + if (length >= 0) { + metrics.recordRequestSize(endpoint, length); + } + } + + @Override + public void aroundWriteTo(WriterInterceptorContext context) + throws IOException, WebApplicationException { + OutputStream original = context.getOutputStream(); + CountingOutputStream counting = new CountingOutputStream(original); + context.setOutputStream(counting); + try { + context.proceed(); + } finally { + Object endpoint = context.getProperty(ENDPOINT); + metrics.recordResponseSize(endpoint instanceof String e ? e : OTHER, + counting.getByteCount()); + } + } + + private String endpoint(ContainerRequestContext requestContext) { + List<Object> matched = requestContext.getUriInfo().getMatchedResources(); + if (matched == null || matched.isEmpty()) { + return UNMATCHED; + } + String path = requestContext.getUriInfo().getPath(); + int slash = path.indexOf('/'); + String root = slash < 0 ? path : path.substring(0, slash); + return knownEndpoints.contains(root) ? root : OTHER; + } +} diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java new file mode 100644 index 0000000000..3316a58e5f --- /dev/null +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/metrics/TikaServerMetrics.java @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.metrics; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; +import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics; +import io.micrometer.core.instrument.binder.system.ProcessorMetrics; +import io.micrometer.core.instrument.binder.system.UptimeMetrics; +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import org.eclipse.jetty.util.thread.ThreadPool; + +import org.apache.tika.pipes.core.PipesParser; +import org.apache.tika.pipes.core.RestartReason; +import org.apache.tika.server.core.ServerStatus; +import org.apache.tika.server.core.resource.AsyncResource; + +/** + * Owns the one {@link PrometheusMeterRegistry} for a tika-server process and every meter + * recorded against it. Constructed only when metrics are enabled; callers hold null + * otherwise, so no meter exists when the feature is off. + * <p> + * Every tag value is drawn from a bounded set (resource root, status class, enum names): + * nothing request-derived ever becomes a tag. + */ +public final class TikaServerMetrics implements AutoCloseable { + + /** + * One bucket set for every duration timer. Twelve explicit boundaries instead of + * Micrometer's percentile histogram, which would emit roughly 70 per series. + */ + public static final Duration[] DURATION_SLOS = { + Duration.ofMillis(10), Duration.ofMillis(50), Duration.ofMillis(100), + Duration.ofMillis(250), Duration.ofMillis(500), Duration.ofSeconds(1), + Duration.ofSeconds(2), Duration.ofSeconds(5), Duration.ofSeconds(10), + Duration.ofSeconds(30), Duration.ofSeconds(60), Duration.ofSeconds(120) + }; + + /** Bucket boundaries for byte-size summaries: 1KB .. 1GB by decades. */ + private static final double[] SIZE_SLOS = { + 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000 + }; + + public static final String REQUESTS = "tika_server_requests"; + public static final String REQUEST_SIZE = "tika_server_request_size_bytes"; + public static final String RESPONSE_SIZE = "tika_server_response_size_bytes"; + public static final String REJECTED = "tika_server_rejected_total"; + public static final String TASKS_ACTIVE = "tika_server_tasks_active"; + public static final String TASKS_STARTED = "tika_server_tasks_started_total"; + public static final String PIPES_WORKERS = "tika_pipes_workers"; + public static final String PIPES_RESTARTS = "tika_pipes_worker_restarts_total"; + public static final String PIPES_QUEUE_DEPTH = "tika_pipes_queue_depth"; + + private final PrometheusMeterRegistry registry; + private final List<AutoCloseable> closeables = new ArrayList<>(); + + public TikaServerMetrics(MetricsConfig config) { + this.registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + for (Map.Entry<String, String> e : config.getCommonTags().entrySet()) { + registry.config().commonTags(e.getKey(), e.getValue()); + } + } + + public MeterRegistry getRegistry() { + return registry; + } + + /** Prometheus text exposition (format 0.0.4). */ + public String scrape() { + return registry.scrape(); + } + + public void bindJvm() { + JvmGcMetrics gc = new JvmGcMetrics(); + gc.bindTo(registry); + closeables.add(gc); + new JvmMemoryMetrics().bindTo(registry); + new JvmThreadMetrics().bindTo(registry); + new ProcessorMetrics().bindTo(registry); + new UptimeMetrics().bindTo(registry); + new FileDescriptorMetrics().bindTo(registry); + } + + public void bindServerStatus(ServerStatus serverStatus) { + Gauge.builder(TASKS_ACTIVE, serverStatus, s -> s.getTasks().size()) + .description("Parse/detect tasks currently running in this server") + .register(registry); + FunctionCounter.builder(TASKS_STARTED, serverStatus, ServerStatus::getFilesProcessed) + .description("Parse/detect tasks started since server start") + .register(registry); + } + + public void bindPipes(PipesParser pipesParser) { + Gauge.builder(PIPES_WORKERS, pipesParser, p -> p.getNumClients() - p.getIdleClientCount()) + .tag("state", "busy") + .description("Forked pipes workers by state") + .register(registry); + Gauge.builder(PIPES_WORKERS, pipesParser, PipesParser::getIdleClientCount) + .tag("state", "idle") + .description("Forked pipes workers by state") + .register(registry); + for (RestartReason reason : RestartReason.values()) { + FunctionCounter.builder(PIPES_RESTARTS, pipesParser, p -> p.getRestartCount(reason)) + .tag("reason", tagValue(reason.name())) + .description("Forked pipes worker restarts by reason") + .register(registry); + } + } + + public void bindAsyncQueue(AsyncResource asyncResource) { + Gauge.builder(PIPES_QUEUE_DEPTH, asyncResource, AsyncResource::getQueueDepth) + .description("Tuples waiting in the /async queue") + .register(registry); + } + + /** The CXF/Jetty request thread pool: queued jobs are HTTP-level backpressure. */ + public void bindJettyThreadPool(ThreadPool threadPool) { + new io.micrometer.core.instrument.binder.jetty.JettyServerThreadPoolMetrics( + threadPool, List.of()).bindTo(registry); + } + + void recordRequest(String endpoint, String method, int status, long nanos) { + Timer.builder(REQUESTS) + .tag("endpoint", endpoint) + .tag("method", method) + .tag("status", statusClass(status)) + .description("HTTP requests handled by the parse-side listener") + .serviceLevelObjectives(DURATION_SLOS) + .register(registry) + .record(nanos, TimeUnit.NANOSECONDS); + String rejected = rejectedReason(status); + if (rejected != null) { + Counter.builder(REJECTED) + .tag("reason", rejected) + .description("Requests refused for capacity reasons") + .register(registry) + .increment(); + } + } + + void recordRequestSize(String endpoint, long bytes) { + sizeSummary(REQUEST_SIZE, endpoint, "Request body bytes (Content-Length)") + .record(bytes); + } + + void recordResponseSize(String endpoint, long bytes) { + sizeSummary(RESPONSE_SIZE, endpoint, "Response entity bytes before compression") + .record(bytes); + } + + private DistributionSummary sizeSummary(String name, String endpoint, String description) { + return DistributionSummary.builder(name) + .tag("endpoint", endpoint) + .baseUnit("bytes") + .description(description) + .serviceLevelObjectives(SIZE_SLOS) + .register(registry); + } + + static String statusClass(int status) { + if (status < 200 || status >= 600) { + return "other"; + } + return (status / 100) + "xx"; + } + + /** + * By status only, which is what tika-server's mapping already encodes: 429 is + * capacity (the /async queue full, or no free fork within maxWaitForClientMillis), + * 503 is a fork that OOM'd, timed out or crashed, 413 is an over-limit body. + */ + static String rejectedReason(int status) { + return switch (status) { + case 429 -> "busy_429"; + case 413 -> "payload_413"; + case 503 -> "crash_503"; + default -> null; + }; + } + + static String tagValue(String enumName) { + return enumName.toLowerCase(Locale.ROOT); + } + + @Override + public void close() { + for (AutoCloseable c : closeables) { + try { + c.close(); + } catch (Exception ignore) { + // best-effort teardown of JVM binders + } + } + registry.close(); + } +} diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java index 1244f5ba31..9c646da885 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/AsyncResource.java @@ -81,6 +81,10 @@ public class AsyncResource { this.maxQueuePauseMillis = maxQueuePauseMillis; } + public int getQueueDepth() { + return asyncProcessor.getQueueDepth(); + } + public ArrayBlockingQueue<FetchEmitTuple> getFetchEmitQueue(int queueSize) { this.queue = new ArrayBlockingQueue<>(queueSize); return queue; diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java index 0f044ead39..64e5d3ca8e 100644 --- a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java @@ -238,4 +238,48 @@ public class TikaServerConfigTest extends TikaTest { TikaServerConfig config = TikaServerConfig.load(commandLine); assertTrue(config.getTlsConfig().isClientAuthenticationRequired()); } + private static Options metricsOptions() { + return new Options() + .addOption(Option.builder("c").longOpt("config").hasArg().get()) + .addOption(Option.builder("p").longOpt("port").hasArg().get()) + .addOption(Option.builder().longOpt("metricsPort").hasArg().get()); + } + + @Test + public void testMetricsOffByDefault() throws Exception { + TikaServerConfig config = TikaServerConfig.load( + new DefaultParser().parse(metricsOptions(), new String[]{})); + assertFalse(config.getMetrics().isEnabled()); + assertEquals(config.getHost(), config.getMetricsHost()); + } + + @Test + public void testMetricsFromJson() throws Exception { + Path path = getConfigPath(getClass(), "tika-config-server-metrics.json"); + TikaServerConfig config = TikaServerConfig.load(new DefaultParser().parse(metricsOptions(), + new String[]{"-c", ProcessUtils.escapeCommandLine(path.toAbsolutePath().toString())})); + assertTrue(config.getMetrics().isEnabled()); + assertEquals(9404, config.getMetrics().getPort()); + assertEquals("127.0.0.1", config.getMetricsHost()); + assertEquals("blue", config.getMetrics().getCommonTags().get("cluster")); + } + + @Test + public void testMetricsPortFromCommandLine() throws Exception { + TikaServerConfig config = TikaServerConfig.load(new DefaultParser().parse(metricsOptions(), + new String[]{"-p", "9998", "--metricsPort", "9500"})); + assertTrue(config.getMetrics().isEnabled()); + assertEquals(9500, config.getMetrics().getPort()); + // no metrics.host: follows the server host + assertEquals(TikaServerConfig.DEFAULT_HOST, config.getMetricsHost()); + } + + @Test + public void testMetricsPortMustDifferFromServerPort() throws Exception { + CommandLine commandLine = new DefaultParser().parse(metricsOptions(), + new String[]{"-p", "9998", "--metricsPort", "9998"}); + TikaConfigException ex = assertThrows(TikaConfigException.class, + () -> TikaServerConfig.load(commandLine)); + assertContains("metrics.port", ex.getMessage()); + } } diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerMetricsIntegrationTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerMetricsIntegrationTest.java new file mode 100644 index 0000000000..9f1ce0e973 --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerMetricsIntegrationTest.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import jakarta.ws.rs.core.Response; +import org.apache.cxf.jaxrs.client.WebClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import org.apache.tika.server.core.metrics.MetricsServer; +import org.apache.tika.utils.ProcessUtils; + +/** + * Runs the real server process with {@code --metricsPort} and checks the scrape output + * and the port isolation both ways. + */ +public class TikaServerMetricsIntegrationTest extends IntegrationTestBase { + + private final int metricsPort = TestPortAllocator.findFreePort(); + private final String metricsEndPoint = "http://localhost:" + metricsPort; + + @Test + @Timeout(120) + public void testScrapeAfterParsesAndWorkerRestart() throws Exception { + startProcess(new String[]{"-config", getConfig("tika-config-server-basic.json"), + "--metricsPort", String.valueOf(metricsPort)}); + awaitServerStartup(); + + assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus()); + assertEquals(503, rmeta(TEST_OOM).getStatus()); + // The OOM'd worker is restarted on its next use. + assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus()); + assertEquals(404, WebClient.create(endPoint + "/no-such-path").get().getStatus()); + + HttpResponse<String> scrape = get(metricsEndPoint + MetricsServer.PATH); + assertEquals(200, scrape.statusCode()); + assertTrue(scrape.headers().firstValue("Content-Type").orElse("") + .startsWith("text/plain; version=0.0.4")); + String body = scrape.body(); + + assertSample(body, "tika_server_requests_seconds_count", + "endpoint=\"rmeta\",method=\"PUT\",status=\"2xx\"", 2.0); + assertSample(body, "tika_server_requests_seconds_count", + "endpoint=\"rmeta\",method=\"PUT\",status=\"5xx\"", 1.0); + assertSample(body, "tika_server_requests_seconds_count", + "endpoint=\"unmatched\",method=\"GET\",status=\"4xx\"", 1.0); + assertSample(body, "tika_server_rejected_total", "reason=\"crash_503\"", 1.0); + assertSample(body, "tika_pipes_worker_restarts_total", "reason=\"oom\"", 1.0); + assertSample(body, "tika_pipes_workers", "state=\"idle\"", 2.0); + assertSample(body, "tika_pipes_workers", "state=\"busy\"", 0.0); + assertTrue(body.contains("tika_server_tasks_started_total"), body); + assertTrue(body.contains("jvm_memory_used_bytes"), body); + assertTrue(body.contains("tika_server_request_size_bytes_count{endpoint=\"rmeta\"}"), body); + assertTrue(body.contains("jetty_threads_"), "CXF request pool metrics missing:\n" + body); + + // Isolation both ways. + assertEquals(404, WebClient.create(endPoint + MetricsServer.PATH).get().getStatus()); + assertEquals(404, WebClient.create(metricsEndPoint + RMETA_PATH) + .accept("application/json") + .put(ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD)).getStatus()); + } + + @Test + @Timeout(120) + public void testOffByDefault() throws Exception { + startProcess(new String[]{"-config", getConfig("tika-config-server-basic.json")}); + awaitServerStartup(); + assertEquals(200, rmeta(TEST_HELLO_WORLD).getStatus()); + assertEquals(404, WebClient.create(endPoint + MetricsServer.PATH).get().getStatus()); + } + + private Response rmeta(String resource) { + return WebClient.create(endPoint + RMETA_PATH) + .accept("application/json") + .put(ClassLoader.getSystemResourceAsStream(resource)); + } + + private static void assertSample(String body, String name, String labels, double expected) { + Matcher m = Pattern.compile("^" + Pattern.quote(name) + "\\{" + Pattern.quote(labels) + + ",?\\} (\\S+)$", Pattern.MULTILINE).matcher(body); + assertTrue(m.find(), "missing " + name + "{" + labels + "} in:\n" + body); + assertEquals(expected, Double.parseDouble(m.group(1)), 0.0, name + "{" + labels + "}"); + } + + private static HttpResponse<String> get(String url) throws Exception { + return HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } + + private String getConfig(String configName) { + try { + return ProcessUtils.escapeCommandLine(Paths + .get(TikaServerMetricsIntegrationTest.class + .getResource("/configs/" + configName) + .toURI()) + .toAbsolutePath() + .toString()); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/MetricsServerTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/MetricsServerTest.java new file mode 100644 index 0000000000..17cc2e0991 --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/MetricsServerTest.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import io.micrometer.core.instrument.Counter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class MetricsServerTest { + + private TikaServerMetrics metrics; + private MetricsServer server; + private String base; + + @BeforeEach + public void setUp() throws Exception { + MetricsConfig config = new MetricsConfig(); + config.setCommonTags(Map.of("cluster", "test")); + metrics = new TikaServerMetrics(config); + Counter.builder("tika_test_total").register(metrics.getRegistry()).increment(3); + server = new MetricsServer("localhost", 0, metrics); + server.start(); + base = "http://localhost:" + server.getPort(); + } + + @AfterEach + public void tearDown() throws Exception { + server.close(); + metrics.close(); + } + + @Test + public void testScrape() throws Exception { + HttpResponse<String> response = get(base + MetricsServer.PATH); + assertEquals(200, response.statusCode()); + assertTrue(response.headers().firstValue("Content-Type").orElse("") + .startsWith("text/plain; version=0.0.4"), response.headers().toString()); + String body = response.body(); + assertTrue(body.contains("# TYPE tika_test_total counter"), body); + assertTrue(body.contains("tika_test_total{cluster=\"test\"} 3.0"), body); + } + + @Test + public void testOnlyMetricsPathIsServed() throws Exception { + assertEquals(404, get(base + "/tika").statusCode()); + assertEquals(404, get(base + "/").statusCode()); + assertEquals(404, get(base + "/metrics/").statusCode()); + HttpResponse<String> post = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create(base + MetricsServer.PATH)) + .POST(HttpRequest.BodyPublishers.noBody()).build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(405, post.statusCode()); + } + + private static HttpResponse<String> get(String url) throws Exception { + return HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/TikaMetricsFilterTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/TikaMetricsFilterTest.java new file mode 100644 index 0000000000..b6f74a1542 --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/metrics/TikaMetricsFilterTest.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Timer; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import org.apache.cxf.jaxrs.JAXRSServerFactoryBean; +import org.apache.cxf.jaxrs.client.WebClient; +import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider; +import org.junit.jupiter.api.Test; + +import org.apache.tika.server.core.CXFTestBase; +import org.apache.tika.server.core.MaxRequestSizeFilter; +import org.apache.tika.server.core.TikaServerParseExceptionMapper; +import org.apache.tika.server.core.resource.TikaResource; +import org.apache.tika.server.core.writer.JSONMessageBodyWriter; + +public class TikaMetricsFilterTest extends CXFTestBase { + + private static final long MAX_BYTES = 64 * 1024; + private static final String HELLO_WORLD = "test-documents/mock/hello_world.xml"; + + private TikaServerMetrics metrics; + + @Path("/boom") + public static class BoomResource { + @GET + public String get() { + // Mapped by JAX-RS itself; an unmapped exception never reaches response filters. + throw new InternalServerErrorException("boom"); + } + } + + @Override + protected void setUpResources(JAXRSServerFactoryBean sf) { + sf.setResourceClasses(TikaResource.class, BoomResource.class); + sf.setResourceProvider(TikaResource.class, new SingletonResourceProvider(tikaResource)); + sf.setResourceProvider(BoomResource.class, new SingletonResourceProvider(new BoomResource())); + } + + @Override + protected void setUpProviders(JAXRSServerFactoryBean sf) { + metrics = new TikaServerMetrics(new MetricsConfig()); + List<Object> providers = new ArrayList<>(); + providers.add(new TikaServerParseExceptionMapper()); + providers.add(new JSONMessageBodyWriter()); + providers.add(new MaxRequestSizeFilter(MAX_BYTES)); + providers.add(new MaxRequestSizeFilter.RequestTooLargeExceptionMapper()); + providers.add(new TikaMetricsFilter(metrics)); + sf.setProviders(providers); + } + + @Test + public void testSuccessRecordsTimerAndSizes() throws Exception { + long helloWorldBytes; + try (InputStream is = ClassLoader.getSystemResourceAsStream(HELLO_WORLD)) { + helloWorldBytes = is.readAllBytes().length; + } + Response response = WebClient + .create(endPoint + "/tika/text") + .accept("text/plain") + .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD)); + assertEquals(200, response.getStatus()); + String body = getStringFromInputStream((InputStream) response.getEntity()); + assertTrue(body.contains("hello world"), body); + + Timer timer = timer("tika", "PUT", "2xx"); + assertNotNull(timer); + assertEquals(1, timer.count()); + DistributionSummary requestSize = metrics.getRegistry() + .find(TikaServerMetrics.REQUEST_SIZE).tag("endpoint", "tika").summary(); + assertNotNull(requestSize); + assertEquals(helloWorldBytes, requestSize.totalAmount(), 0.0); + // Recorded after the last byte is flushed, so possibly a beat after the client returns. + DistributionSummary responseSize = null; + for (int i = 0; i < 50 && responseSize == null; i++) { + responseSize = metrics.getRegistry() + .find(TikaServerMetrics.RESPONSE_SIZE).tag("endpoint", "tika").summary(); + if (responseSize == null) { + Thread.sleep(50); + } + } + assertNotNull(responseSize); + assertTrue(responseSize.totalAmount() >= 11.0, "response bytes: " + responseSize.totalAmount()); + } + + @Test + public void testPayloadTooLargeCountsAsRejected() { + Response response = WebClient + .create(endPoint + "/tika/text") + .put(new ByteArrayInputStream(new byte[(int) MAX_BYTES * 4])); + assertEquals(413, response.getStatus()); + assertEquals(1, timer("tika", "PUT", "4xx").count()); + assertEquals(1.0, rejected("payload_413").count(), 0.0); + } + + @Test + public void testThrownExceptionIs5xx() { + Response response = WebClient.create(endPoint + "/boom").get(); + assertEquals(500, response.getStatus()); + // /boom is not a tika-server endpoint name, so it folds into "other" + assertEquals(1, timer(TikaMetricsFilter.OTHER, "GET", "5xx").count()); + } + + @Test + public void testUnmatchedPathsAreBoundedTags() { + long timersBefore = -1; + for (int i = 0; i < 25; i++) { + Response response = WebClient.create(endPoint + "/no-such-" + i + "/x").get(); + assertEquals(404, response.getStatus()); + long timers = metrics.getRegistry().find(TikaServerMetrics.REQUESTS).timers().size(); + if (timersBefore >= 0) { + assertEquals(timersBefore, timers, "a new request timer appeared for request " + i); + } + timersBefore = timers; + } + assertEquals(25, timer(TikaMetricsFilter.UNMATCHED, "GET", "4xx").count()); + } + + private Timer timer(String endpoint, String method, String status) { + return metrics.getRegistry().find(TikaServerMetrics.REQUESTS) + .tag("endpoint", endpoint).tag("method", method).tag("status", status).timer(); + } + + private Counter rejected(String reason) { + return metrics.getRegistry().find(TikaServerMetrics.REJECTED).tag("reason", reason).counter(); + } +} diff --git a/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics.json b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics.json new file mode 100644 index 0000000000..9ce5c95ff2 --- /dev/null +++ b/tika-server/tika-server-core/src/test/resources/configs/tika-config-server-metrics.json @@ -0,0 +1,16 @@ +{ + "server": { + "port": 9999, + "endpoints": [ + "tika" + ], + "metrics": { + "port": 9404, + "host": "127.0.0.1", + "commonTags": { + "cluster": "blue" + } + } + }, + "plugin-roots": "target/plugins" +} diff --git a/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE b/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE index f4f9fb4839..a48fe3c4f9 100644 --- a/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE +++ b/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE @@ -1872,3 +1872,13 @@ jetty-session, jetty-util) Apache Tika elects the Apache License, Version 2.0, whose terms appear above. The Jetty jars do not carry a copy of either license; both texts are available at the URLs above. + +Prometheus Java client (prometheus-metrics-core, prometheus-metrics-config, +prometheus-metrics-model, prometheus-metrics-exposition-formats, +prometheus-metrics-exposition-textformats, prometheus-metrics-tracer-common) + + Copyright The Prometheus Authors + + Licensed under the Apache License, Version 2.0, whose terms appear above. + The jars do not carry a copy of the license; the upstream project is + https://github.com/prometheus/client_java
