From c7f1b9b267b3ddbf126fa32158a5b62f413279ec Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:18:44 +0200 Subject: [PATCH 1/2] Backend: metric schema and probes (#6001), focused test slices (#6002) Metrics: a published, stable schema (MetricSchema, GET /manage/schema) with Micrometer names; a figure a runtime cannot measure is absent, never zero; pool pending/timeout gauges; liveness and readiness probes; a portable Grafana dashboard. A native test (BackendOperationsTest) holds the packaged ParparVM server to the same contract. Tests: @WebMvcTest and @DataJpaTest slices that fail at build time on a missing dependency, per-test rollback, opt-in management/telemetry/MCP via @BackendTest(features), and the ManagementContract and OtlpTestCollector fixtures, all running on the JVM and compiled. Co-Authored-By: Claude Opus 5.5 --- .../backend/testing/GreetingSliceTest.java | 47 ++ .../backend/testing/ManagedServerTest.java | 48 ++ .../Backend-Observability.asciidoc | 227 +++++++- docs/developer-guide/Backend-Testing.asciidoc | 92 +++- .../codename1/backend/MetricSchemaTest.java | 307 +++++++++++ .../maven/processors/BackendBeans.java | 67 ++- .../processors/BackendTestGenerator.java | 401 ++++++++++++++- .../maven/processors/BackendTests.java | 11 +- .../processors/BackendTestGeneratorTest.java | 200 ++++++++ vm/backend/dashboards/backend-overview.json | 483 ++++++++++++++++++ .../demo/opscheck/com/demo/OpsServer.java | 64 +++ .../codename1/impl/backend/RuntimeGauges.java | 91 ++++ .../codename1/impl/backend/RuntimeGauges.java | 63 +++ .../src/com/codename1/backend/DataSource.java | 39 +- .../com/codename1/backend/metrics/Gauge.java | 7 +- .../backend/metrics/MetricSchema.java | 336 ++++++++++++ .../codename1/backend/metrics/Metrics.java | 245 +++++++-- .../backend/otel/OtlpMetricExporter.java | 4 + .../codename1/impl/backend/Management.java | 25 +- .../backend/test/BackendTestExtension.java | 34 +- .../codename1/backend/test/BackendTest.java | 25 + .../backend/test/ContractChecks.java | 43 ++ .../codename1/backend/test/DataJpaTest.java | 76 +++ .../backend/test/ManagementContract.java | 121 +++++ .../backend/test/OtlpTestCollector.java | 137 +++++ .../codename1/backend/test/WebMvcTest.java | 83 +++ .../impl/backend/test/TestContext.java | 5 + .../impl/backend/test/TestTransactions.java | 61 +++ .../translator/BackendOperationsTest.java | 207 ++++++++ .../tools/translator/BackendTestSupport.java | 10 + 30 files changed, 3460 insertions(+), 99 deletions(-) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingSliceTest.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ManagedServerTest.java create mode 100644 maven/backend/src/test/java/com/codename1/backend/MetricSchemaTest.java create mode 100644 vm/backend/dashboards/backend-overview.json create mode 100644 vm/backend/demo/opscheck/com/demo/OpsServer.java create mode 100644 vm/backend/impl/javase/com/codename1/impl/backend/RuntimeGauges.java create mode 100644 vm/backend/impl/parparvm/com/codename1/impl/backend/RuntimeGauges.java create mode 100644 vm/backend/src/com/codename1/backend/metrics/MetricSchema.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ContractChecks.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/DataJpaTest.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ManagementContract.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/OtlpTestCollector.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/WebMvcTest.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/TestTransactions.java create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/BackendOperationsTest.java diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingSliceTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingSliceTest.java new file mode 100644 index 00000000000..31912026297 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingSliceTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +// tag::backend-test-web-slice[] +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.WebMvcTest; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +@WebMvcTest(value = GreetingApi.class, includes = Greetings.class) +class GreetingSliceTest { + @Autowired + private MockMvc mvc; + + @Test + void greetsWithOnlyTheControllerWired() throws Exception { + mvc.perform(get("/greet/{name}", "Ada")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.greeting").value("Hello, Ada")); + } +} +// end::backend-test-web-slice[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ManagedServerTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ManagedServerTest.java new file mode 100644 index 00000000000..8e0ac14f4a9 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ManagedServerTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +// tag::backend-test-management-contract[] +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.ManagementContract; +import com.codename1.backend.test.TestRestTemplate; + +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT, + features = BackendTest.Feature.MANAGEMENT, + properties = "cn1.management.token=test-token") +class ManagedServerTest implements ManagementContract { + @Autowired + private TestRestTemplate rest; + + @Override + public TestRestTemplate managementClient() { + return rest; + } + + @Override + public String managementToken() { + return "test-token"; + } +} +// end::backend-test-management-contract[] diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index de3bceaf8b2..72efe45ac4b 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -126,48 +126,168 @@ temporality. `cn1.otel.metrics.enabled=false` turns metrics off while leaving traces on. The management endpoints described below record and serve the same metrics whether anything exports them or not. -==== What the server records +==== The metric schema -[cols="3,1,2", options="header"] +The metrics the server records about itself have a published schema. Their names, +units and attributes are stable, so a dashboard or an alert written against them +keeps working when the server is upgraded, and it works the same against a server +running on the JVM and a packaged native one. Each metric has one of three +availabilities: *always*, *database* (when the server has a database pool), or +*JVM* (the packaged server has no equivalent). + +[cols="3,2,3,1,3", options="header"] |=== -| Instrument | Kind | Attributes +| Instrument | Kind | Attributes | Measured | Micrometer equivalent -| `http.server.request.duration`, in milliseconds -| histogram -| `http.route` (the route template), `http.request.method`, - `http.response.status_code` +| `http.server.request.duration` +| histogram, ms +| `http.route`, `http.request.method`, `http.response.status_code` +| always +| `http.server.requests` | `http.server.active_requests` | gauge -| +| +| always +| `http.server.requests.active` | `http.server.open_connections` | gauge -| +| +| always +| `tomcat.connections.current` | `cn1.server.websocket_connections` | gauge -| +| +| always +| none + +| `cn1.server.requests_served` +| gauge, cumulative +| +| always +| none + +| `cn1.server.connections_refused` +| gauge, cumulative +| +| always +| none + +| `db.client.connection.count` +| gauge +| +| database +| `hikaricp.connections` -| `cn1.server.requests_served`, `cn1.server.connections_refused` +| `db.client.connection.idle` | gauge -| +| +| database +| `hikaricp.connections.idle` -| `db.client.connection.count`, `db.client.connection.idle` +| `db.client.connection.max` | gauge -| (when the server has a database) +| +| database +| `hikaricp.connections.max` + +| `db.client.connection.pending_requests` +| gauge +| +| database +| `hikaricp.connections.pending` + +| `db.client.connection.timeouts` +| gauge, cumulative +| +| database +| `hikaricp.connections.timeout` | `cn1.task.queue_depth` | gauge | `cn1.executor` +| always +| `executor.queued` -| `cn1.scheduler.run.duration`, in milliseconds -| histogram -| `cn1.job`, `cn1.outcome` (`success` or `failure`) +| `cn1.task.active` +| gauge +| `cn1.executor` +| always +| `executor.active` + +| `cn1.task.completed` +| gauge, cumulative +| `cn1.executor` +| always +| `executor.completed` -| `process.runtime.memory.used`, `process.uptime` +| `cn1.task.pool_size` | gauge -| +| `cn1.executor` +| always +| `executor.pool.size` + +| `cn1.scheduler.run.duration` +| histogram, ms +| `cn1.job`, `cn1.outcome` +| always +| `tasks.scheduled.execution` + +| `process.runtime.memory.used` +| gauge, bytes +| +| always +| `jvm.memory.used` + +| `process.runtime.memory.committed` +| gauge, bytes +| +| always +| `jvm.memory.committed` + +| `process.runtime.memory.limit` +| gauge, bytes +| +| JVM +| `jvm.memory.max` + +| `process.uptime` +| gauge, seconds +| +| always +| `process.uptime` + +| `jvm.thread.count` +| gauge +| +| JVM +| `jvm.threads.live` + +| `jvm.class.count` +| gauge +| +| JVM +| `jvm.classes.loaded` + +| `jvm.cpu.count` +| gauge +| +| JVM +| `system.cpu.count` + +| `cn1.jvm.gc.collections` +| gauge, cumulative +| +| JVM +| `jvm.gc.pause` (count) + +| `cn1.jvm.gc.time` +| gauge, cumulative, ms +| +| JVM +| `jvm.gc.pause` (total) |=== The route attribute is the template -- `GET /notes/{id}` is recorded as @@ -175,6 +295,51 @@ The route attribute is the template -- `GET /notes/{id}` is recorded as ask for. A request that fails, whether in a handler or in the session store, is recorded with status 500. +`GET /manage/schema` returns this table for the running server as JSON: its +runtime (`jvm` or `native`) and, for each metric, its kind, unit, attributes, +temporality, Micrometer name and whether this server measures it. + +==== Unavailable is not zero + +A metric a server can't measure isn't reported as `0`. Its instrument isn't registered, or reads +no value, so it's absent from the JSON view, the Prometheus scrape and the OTLP +export, and `/manage/schema` lists it with `"available": false` and the reason. +On a packaged server that means the JVM-only rows above; on a server with no +database it means the pool rows. A gauge whose source can't be read at collection +time is likewise left out rather than written as zero. An alert on "thread count is +0" therefore never fires on a native server by accident: query the series' absence +instead, or read the schema. + +The schema also lists, under `unmeasured`, the Micrometer meters a Spring Boot +dashboard commonly reads that neither runtime reports, each with its reason: +`jvm.gc.pause` as a per-pause histogram, `jvm.gc.memory.allocated` and +`jvm.gc.memory.promoted`, per-pool `jvm.memory.used`, `jvm.buffer.memory.used`, +`jvm.threads.states`, `process.cpu.usage`, `system.cpu.usage`, +`system.load.average.1m`, `process.files.open`, `hikaricp.connections.usage`, +`hikaricp.connections.acquire` and `logback.events`. + +==== Moving a Spring Boot dashboard + +The Micrometer column says which series to switch to. In Prometheus form a name's +dots become underscores, so a panel on `hikaricp_connections_active` reads +`db_client_connection_count - db_client_connection_idle`, one on +`http_server_requests_seconds_count` reads `http_server_request_duration_count`, +and `tomcat_connections_current_connections` becomes +`http_server_open_connections`. Two differences to adjust for: + +* **Units.** Durations are milliseconds, not seconds, so a latency threshold of + `0.5` becomes `500`, and histogram buckets are in milliseconds. +* **Attribute names.** Requests carry the OpenTelemetry attributes + `http.route`, `http.request.method` and `http.response.status_code` rather than + Micrometer's `uri`, `method` and `status`. + +`vm/backend/dashboards/backend-overview.json` in the Codename One repository is a +Grafana dashboard written against the schema: request rate, latency percentiles, +errors, connections, the database pool, executors, scheduled jobs, memory and +uptime. Import it and pick the Prometheus data source that scrapes +`/manage/prometheus`. It uses only metrics every runtime measures, so its panels +are filled on the JVM and on a packaged server alike. + ==== Managed beans The application's own metrics follow the JMX model -- attributes to watch and @@ -262,6 +427,9 @@ load balancer and for an operator: ---- GET /manage/health public; 503 while starting or draining +GET /manage/health/liveness public; 200 while the process serves at all +GET /manage/health/readiness public; 503 while starting or draining +GET /manage/schema the metric schema, as JSON GET /manage/metrics every metric, as JSON GET /manage/prometheus every metric, in the Prometheus text format GET /manage/jobs the scheduled jobs and their last runs @@ -291,7 +459,9 @@ still fail to start. links the endpoints into a packaged server. | `cn1.management.token` -| The bearer token every endpoint but health requires. Outside a development +| The bearer token every endpoint but health and the probes requires, sent as + `Authorization: Bearer `. A missing or wrong token is answered with 401, and an operation on a server + that has no token set with 403. Outside a development profile the server refuses to start with the endpoints on and no token, since the metrics and managed beans would be readable by anyone who can reach the port. @@ -322,6 +492,23 @@ On a development profile without a token, the read-only views are open to anyone who can reach the port, which is a laptop. An operation needs the token on every profile, because it changes the running server. +==== Probes and draining + +The two probes follow the split Kubernetes and Spring Boot's +`/actuator/health/liveness` and `/actuator/health/readiness` make. Liveness says +the process is running and answering, and stays 200 while it starts and drains; +point a liveness probe at it, so a slow start or a drain never gets the container +restarted. Readiness is 503 until start-up finishes and again from the moment the +server begins to stop; point a readiness probe or a load balancer health check at +it. `/manage/health` answers as readiness does, with the state in the body. + +When the server is told to stop (SIGTERM, or the platform's equivalent), it turns +readiness to 503, stops accepting new connections, lets the requests in flight +finish within `cn1.server.shutdownTimeoutMillis`, runs the exporters' last export, +and exits. Give the platform's termination grace period a few seconds more than +that timeout. The probes need no token on any profile, since a load balancer has +none to send. + Health answers 503 while the server drains, so a load balancer stops sending it new requests before it stops accepting them. The Prometheus view turns each name into a Prometheus one, replacing the dots with underscores and adding `_total` to a diff --git a/docs/developer-guide/Backend-Testing.asciidoc b/docs/developer-guide/Backend-Testing.asciidoc index 22bbab4fa54..9991b47e717 100644 --- a/docs/developer-guide/Backend-Testing.asciidoc +++ b/docs/developer-guide/Backend-Testing.asciidoc @@ -245,6 +245,63 @@ what the test is exercising. Mockito builds classes while it runs, which a nativ binary can't do, so `@MockitoBean` tests run only on the JVM. A `@Primary` test bean works in both. +=== Testing one slice of the application + +`@BackendTest` starts the whole application. A slice starts only the part a test +is about, so the test starts faster and a failure points at that part. + +`@WebMvcTest` wires the controllers it names, and the beans its `includes` lists, +behind `MockMvc`. Nothing else is created: no other controller, repository, +scheduled job or bean is constructed, so a bean whose constructor needs a database +or a remote service can't slow the test down or break it. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingSliceTest.java[tag=backend-test-web-slice,indent=0] +---- + +A dependency the slice leaves out is a build error, not a failure when the test +runs. The error names the bean and the field or parameter that needs it, and the +three ways out: list it in `includes`, replace it with a `@MockitoBean`, or declare +it in a `@TestConfiguration`. A class named as a controller that isn't one is a +build error too. + +`@DataJpaTest` wires the entities, the generated repositories and the beans its +`includes` lists, with no web layer (its environment is `NONE`). It runs against +its own database, an in-memory SQLite one unless its `database` attribute names a +file or a URL, and the test's own settings can't point it at a shared one by +accident. Each test method runs in a transaction that is rolled back when it +returns, whether it passed or failed, so tests can't see each other's rows; +`rollback = false` keeps what a test wrote. A test can inject a repository +interface the application never implements and gets the generated one. + +Both slices take `properties`, `profile` and `classes` as `@BackendTest` does, and +both run compiled, as the next sections describe. + +=== Turning production features on + +A test's application starts without the management endpoints, the MCP endpoint +and the OpenTelemetry exporters, whatever the module's settings say, so a test +that doesn't exercise them doesn't pay for them. `features` turns them on for one +test class: `MANAGEMENT`, `TELEMETRY`, `MCP`, or `AS_PACKAGED` for the set the +packaged server is built with. + +The test library has two fixtures for such tests, and both run on the JVM and +compiled: + +* `ManagementContract` is an interface of `@Test` default methods that checks the + management contract: the probes need no token, every other endpoint answers 401 + without the right one, the schema describes its metrics, and the Prometheus + scrape is typed. A test class implements it and supplies a client and the token. +* `OtlpTestCollector` is an OTLP receiver on a loopback port. Point + `cn1.otel.endpoint` at its `getEndpoint()`, turn `TELEMETRY` on, and + `await` the export requests the server sends to `/v1/traces` or `/v1/metrics`. + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ManagedServerTest.java[tag=backend-test-management-contract,indent=0] +---- + === Running the tests compiled The server ships as a native binary, and the tests can run that way too: @@ -314,15 +371,17 @@ A compiled run has these limits: * **Packages.** The annotations and classes live in `com.codename1.backend.test`, and `@Autowired` is `com.codename1.backend.annotations.Autowired`. * **`@BackendTest` instead of `@SpringBootTest`.** Its options are the - web environment, `properties`, `profile` and `classes`. `@WebMvcTest`-style - slices don't exist; every test runs the whole application. + web environment, `properties`, `profile`, `classes` and `features`. + `@WebMvcTest` and `@DataJpaTest` are the two slices; there are no others, such + as `@JsonTest` or `@RestClientTest`. * **Wiring at build time.** The test's application is generated by the build rather than assembled when the test starts. An injection that can't be satisfied fails the build. -* **No management, MCP or tracing.** The test's application is built without the - management endpoints, the MCP endpoint and the OpenTelemetry exporters, - whatever the module's `application.properties` asks for, and starts without - them. +* **No management, MCP or tracing unless asked.** The test's application is + built without the management endpoints, the MCP endpoint and the OpenTelemetry + exporters, whatever the module's `application.properties` asks for, unless the + test's `features` names them. Spring starts Actuator in every + `@SpringBootTest`. * **One application at a time.** Spring can keep several contexts cached. Here a class with a different configuration stops the running application before it starts its own. @@ -331,3 +390,24 @@ A compiled run has these limits: threads its tasks run on. * **The JSON path subset.** `jsonPath` supports the expressions listed above, without filters or functions other than `length()`. + +==== Migrating Spring Boot tests + +Most of a Spring Boot test carries over with its imports changed: + +* `@SpringBootTest` becomes `@BackendTest`, with the same `webEnvironment`, + `properties` and `classes`. `@ActiveProfiles("x")` becomes `profile = "x"`. +* `@WebMvcTest(controllers = X.class)` stays `@WebMvcTest(X.class)`. Spring's slice + scans for `@ControllerAdvice`, converters and filters and leaves services out; this + one leaves out everything not named, so list the services the controller uses in + `includes` or mock them with `@MockitoBean`. The build error says which. +* `@DataJpaTest` stays `@DataJpaTest`. `@AutoConfigureTestDatabase` has no + equivalent, because the slice always has its own database; name it with + `database`. `TestEntityManager` has no equivalent either; use the repositories. +* `@MockBean` and `@MockitoBean` become `@MockitoBean`, and `@TestConfiguration` + classes carry over. Mockito tests run on the JVM only. +* `MockMvc`, `TestRestTemplate`, `@LocalServerPort` and the JUnit 5 assertions keep + their names and the common methods. +* A test of `/actuator/health` becomes one of `/manage/health/liveness` and + `/manage/health/readiness` with `features = Feature.MANAGEMENT`, and a test of + `/actuator/prometheus` one of `/manage/prometheus` with the management token. diff --git a/maven/backend/src/test/java/com/codename1/backend/MetricSchemaTest.java b/maven/backend/src/test/java/com/codename1/backend/MetricSchemaTest.java new file mode 100644 index 00000000000..dd725971392 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/MetricSchemaTest.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import com.codename1.backend.metrics.Instrument; +import com.codename1.backend.metrics.MetricSchema; +import com.codename1.backend.metrics.Metrics; +import com.codename1.impl.backend.Management; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The published metric schema held to what a running server serves: every +/// instrument it lists is recorded with the kind, unit and Prometheus name it +/// declares, a figure the server cannot measure is absent rather than zero, and +/// the sample dashboard reads nothing a runtime may lack. +class MetricSchemaTest { + private static final HttpServer.Handler NONE = new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + return "/hello".equals(request.getTarget()) ? HttpServer.Response.text(200, "hi") : null; + } + }; + + @Test + @DisplayName("the schema names each instrument once, with a unit and a Prometheus name of its own") + void schemaIsWellFormed() { + Set names = new HashSet(); + Set prometheus = new HashSet(); + for (Object element : MetricSchema.entries()) { + MetricSchema.Entry e = (MetricSchema.Entry) element; + assertTrue(names.add(e.getName()), "listed twice: " + e.getName()); + assertTrue(prometheus.add(e.getPrometheusName()), "two entries scrape as " + + e.getPrometheusName()); + assertFalse(e.getUnit().isEmpty(), e.getName() + " has no unit"); + assertTrue(MetricSchema.CUMULATIVE.equals(e.getTemporality()) + || MetricSchema.INSTANT.equals(e.getTemporality()), e.getName()); + assertTrue(MetricSchema.ALWAYS.equals(e.getAvailability()) + || MetricSchema.DATABASE.equals(e.getAvailability()) + || MetricSchema.JVM.equals(e.getAvailability()), e.getName()); + assertTrue(e == MetricSchema.get(e.getName())); + } + for (Object element : MetricSchema.unmeasured()) { + String[] u = (String[]) element; + assertFalse(u[1].isEmpty(), u[0] + " is listed without a reason"); + } + } + + @Test + @DisplayName("a server with a database records every instrument of the schema as declared") + void everyInstrumentIsServedAsDeclared() throws Exception { + File db = File.createTempFile("schema", ".db"); + db.deleteOnExit(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(Config.DATASOURCE_URL, db.getAbsolutePath()); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() + .requiresDataSource().handler(NONE).start(); + try { + assertEquals(200, get(port, "/hello", null).status); + Map schema = Json.parseObject(get(port, "/manage/schema", null).body); + assertEquals("jvm", schema.get("runtime")); + String scrape = get(port, "/manage/prometheus", null).body; + for (Object element : (List) schema.get("metrics")) { + Map m = (Map) element; + String name = (String) m.get("name"); + MetricSchema.Entry entry = MetricSchema.get(name); + assertNotNull(entry, name); + assertEquals(Boolean.TRUE, m.get("available"), name + ": " + m.get("reason")); + Instrument recorded = Metrics.get(name); + assertEquals(entry.getKind(), recorded.getKind(), name + " kind"); + assertEquals(entry.getUnit(), recorded.getUnit(), name + " unit"); + String type = entry.getKind() == Instrument.COUNTER ? "counter" + : entry.getKind() == Instrument.HISTOGRAM ? "histogram" : "gauge"; + assertTrue(scrape.contains("# TYPE " + entry.getPrometheusName() + " " + type + "\n"), + name + " is not scraped as " + entry.getPrometheusName() + " " + type); + } + // The figures themselves, not only their presence. + assertTrue(value("db.client.connection.max") >= 1); + assertEquals(0.0, value("db.client.connection.pending_requests"), 0.0); + assertTrue(value("process.runtime.memory.committed") + >= value("process.runtime.memory.used")); + assertTrue(value("jvm.thread.count") > 0); + assertTrue(scrape.contains("http_server_request_duration_count{"), scrape); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("a server without a database reports no pool figures, and says why") + void absentFiguresAreNotZero() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() + .handler(NONE).start(); + try { + Map schema = Json.parseObject(get(port, "/manage/schema", null).body); + String scrape = get(port, "/manage/prometheus", null).body; + Map view = Json.parseObject(get(port, "/manage/metrics", null).body); + int pool = 0; + for (Object element : (List) schema.get("metrics")) { + Map m = (Map) element; + if (!MetricSchema.DATABASE.equals(m.get("availability"))) { + continue; + } + pool++; + assertEquals(Boolean.FALSE, m.get("available"), (String) m.get("name")); + assertEquals("This server has no database pool.", m.get("reason")); + String prom = (String) m.get("prometheus"); + assertFalse(scrape.contains(" " + prom + " "), + prom + " is named in the scrape of a server with no pool"); + assertFalse(view.containsKey(m.get("name")), + m.get("name") + " is listed by the JSON view of a server with no pool"); + } + assertEquals(5, pool); + assertFalse(((List) schema.get("unmeasured")).isEmpty()); + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("the pool counts the borrowers it keeps waiting and the borrows that time out") + void poolSaturationIsMeasured() throws Exception { + File file = File.createTempFile("pool", ".db"); + file.deleteOnExit(); + final DataSource pool = DataSource.open(file.getAbsolutePath(), 1, 1000, 400); + try { + Database held = pool.borrow(); + final AtomicReference outcome = new AtomicReference(); + Thread waiter = new Thread(new Runnable() { + public void run() { + try { + pool.release(pool.borrow()); + } catch (Throwable err) { + outcome.set(err); + } + } + }); + waiter.start(); + long deadline = System.currentTimeMillis() + 5000; + while (pool.getPendingCount() == 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + assertEquals(1, pool.getPendingCount()); + waiter.join(5000); + assertTrue(outcome.get() instanceof IOException, String.valueOf(outcome.get())); + assertEquals(0, pool.getPendingCount()); + assertEquals(1L, pool.getTimeoutCount()); + pool.release(held); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("readiness follows health, liveness stays up, and only health is public") + void probesAndTheirAuthentication() throws Exception { + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + settings.setProperty(Management.ENABLED, "true"); + settings.setProperty(Management.TOKEN, "s3cret"); + Backend backend = Backend.builder(Config.of(settings, "prod")).quiet().management() + .handler(NONE).start(); + try { + Reply ready = get(port, "/manage/health/readiness", null); + assertEquals(200, ready.status); + assertEquals("UP", Json.parseObject(ready.body).get("status")); + Reply live = get(port, "/manage/health/liveness", null); + assertEquals(200, live.status); + assertEquals("UP", Json.parseObject(live.body).get("status")); + assertEquals(200, get(port, "/manage/health", null).status); + for (String path : new String[] {"/manage/schema", "/manage/metrics", "/manage/prometheus"}) { + assertEquals(401, get(port, path, null).status, path + " without the token"); + assertEquals(401, get(port, path, "wrong").status, path + " with a wrong token"); + assertEquals(200, get(port, path, "s3cret").status, path + " with the token"); + } + } finally { + backend.stop(); + } + } + + @Test + @DisplayName("the sample dashboard reads only instruments every runtime reports") + void theDashboardIsPortable() throws Exception { + File dashboard = new File("../../vm/backend/dashboards/backend-overview.json"); + assertTrue(dashboard.isFile(), dashboard.getAbsolutePath()); + String json = new String(Files.readAllBytes(dashboard.toPath()), StandardCharsets.UTF_8); + Map parsed = Json.parseObject(json); + Set portable = new HashSet(); + for (Object element : MetricSchema.entries()) { + MetricSchema.Entry e = (MetricSchema.Entry) element; + if (!MetricSchema.JVM.equals(e.getAvailability())) { + portable.add(e.getPrometheusName()); + } + } + // A metric selector is a name followed by its label matchers. + Pattern selector = Pattern.compile("([a-zA-Z_:][a-zA-Z0-9_:]*)\\{"); + Set used = new HashSet(); + for (Object panel : (List) parsed.get("panels")) { + Object targets = ((Map) panel).get("targets"); + if (!(targets instanceof List)) { + continue; + } + for (Object target : (List) targets) { + Matcher m = selector.matcher((String) ((Map) target).get("expr")); + while (m.find()) { + String name = m.group(1).replaceAll("_(bucket|sum|count)$", ""); + if (!portable.contains(name)) { + name = m.group(1); + } + assertTrue(portable.contains(name), "the dashboard reads " + m.group(1) + + ", which is not a metric every runtime reports"); + used.add(name); + } + } + } + assertTrue(used.size() >= 15, "the dashboard reads only " + used); + } + + private static double value(String name) { + List points = Metrics.get(name).points(); + assertFalse(points.isEmpty(), name + " has no point"); + return ((Number) ((Map) points.get(0)).get("value")).doubleValue(); + } + + private static final class Reply { + int status; + String body; + } + + private static Reply get(int port, String path, String token) throws IOException { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + if (token != null) { + c.setRequestProperty("Authorization", "Bearer " + token); + } + Reply r = new Reply(); + r.status = c.getResponseCode(); + InputStream in = r.status >= 400 ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + if (in != null) { + byte[] buffer = new byte[4096]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + in.close(); + } + r.body = new String(out.toByteArray(), StandardCharsets.UTF_8); + return r; + } + + private static int freePort() throws IOException { + ServerSocket s = new ServerSocket(0); + try { + return s.getLocalPort(); + } finally { + s.close(); + } + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 6d4e1cc1f83..69d5a0fea15 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -464,6 +464,14 @@ static final class Aspect { /// to the field of the entry point that holds its one instance. final Map repositories = new LinkedHashMap(); int requestSlots; + + /// Which slot counter a scoped bean draws from: 0 request, 1 session, 2 lazy. + private static int slotKind(Bean b) { + if (REQUEST.equals(b.scope)) { + return 0; + } + return SESSION.equals(b.scope) ? 1 : 2; + } int sessionSlots; int lazySlots; /// The package the entry point is written into. @@ -477,6 +485,12 @@ static final class Aspect { private Set testConfigurations; private List testMocks; private Set testWeaveable; + /// A slice's whole application, resolved, whose scoped and lazy slot + /// numbers this model keeps; null outside a slice. + private BackendBeans slotSource; + /// What a slice's test is told when it injects a bean the slice left out; + /// null outside a slice. + String sliceHint; /// Sources this pass compiled, by binary name, so tests can read them. final Map sources = new LinkedHashMap(); @@ -531,7 +545,22 @@ static BackendBeans forTests(ProcessorContext ctx, Set scope, Set configurations, List mocks, Set weaveable, String entryPackage) throws ProcessingException { + return forTests(ctx, scope, configurations, mocks, weaveable, entryPackage, null, null); + } + + /// [#forTests] for a slice: `slotSource` is the whole application, and the + /// stand-ins the main build compiled index scope storage by ITS slots, so a + /// slice that numbered only its own scoped beans would hand one bean + /// another's instance. `hint` ends the error for an injection the slice + /// cannot satisfy. + static BackendBeans forTests(ProcessorContext ctx, Set scope, + Set configurations, List mocks, + Set weaveable, String entryPackage, + BackendBeans slotSource, String hint) + throws ProcessingException { BackendBeans out = new BackendBeans(ctx); + out.slotSource = slotSource; + out.sliceHint = hint; out.testScope = scope; out.testConfigurations = configurations; out.testMocks = mocks; @@ -2085,6 +2114,39 @@ private void resolve() { b.slot = lazySlots++; } } + if (slotSource != null) { + // A slice keeps the whole application's slot for a bean both have, so + // the generated scope code agrees; a scoped bean only the slice has (a + // test configuration's) gets a fresh slot above the whole range, never + // one a remapped bean may also be given. + int request = slotSource.requestSlots; + int session = slotSource.sessionSlots; + int lazy = slotSource.lazySlots; + for (Bean b : beans) { + if (b.slot < 0) { + continue; + } + int kind = slotKind(b); + Bean match = null; + for (Bean whole : slotSource.beans) { + if (whole.slot >= 0 && whole.name.equals(b.name) && slotKind(whole) == kind) { + match = whole; + } + } + if (match != null) { + b.slot = match.slot; + } else if (kind == 0) { + b.slot = request++; + } else if (kind == 1) { + b.slot = session++; + } else { + b.slot = lazy++; + } + } + requestSlots = request; + sessionSlots = session; + lazySlots = lazy; + } for (Bean b : beans) { checkAccessibility(b); AnnotatedClass where = b.cls != null && ctx.lookup(b.type) != null ? b.cls @@ -2360,8 +2422,9 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { if (matches.isEmpty()) { if (p.required) { ctx.error(where, p.where + " needs a " + p.type.getClassName() + ", and no " - + "bean has that type. Annotate the implementing class @Component, " - + "or declare a @Bean method returning one."); + + "bean has that type. " + (sliceHint != null ? sliceHint + : "Annotate the implementing class @Component, " + + "or declare a @Bean method returning one.")); } return; } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java index a464bc99374..8fa88496eee 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java @@ -35,6 +35,7 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -71,6 +72,16 @@ /// the one the main build used. final class BackendTestGenerator { static final String BACKEND_TEST = "Lcom/codename1/backend/test/BackendTest;"; + static final String WEB_MVC_TEST = "Lcom/codename1/backend/test/WebMvcTest;"; + static final String DATA_JPA_TEST = "Lcom/codename1/backend/test/DataJpaTest;"; + /// Every annotation that makes a class a backend test, the whole application + /// first and then the slices. + static final String[] TEST_ANNOTATIONS = {BACKEND_TEST, WEB_MVC_TEST, DATA_JPA_TEST}; + /// The setting a `@DataJpaTest` points at its declared database. + static final String DATASOURCE_URL = "cn1.datasource.url"; + static final int SLICE_NONE = 0; + static final int SLICE_WEB = 1; + static final int SLICE_DATA = 2; static final String TEST_CONFIGURATION = "Lcom/codename1/backend/test/TestConfiguration;"; static final String MOCKITO_BEAN = "Lcom/codename1/backend/test/MockitoBean;"; static final String LOCAL_SERVER_PORT = "Lcom/codename1/backend/test/LocalServerPort;"; @@ -97,6 +108,8 @@ final class BackendTestGenerator { static final String MAIN_CLASS = "cn1app.BackendTestMain"; private static final String[] WEB_ENVIRONMENTS = {"MOCK", "RANDOM_PORT", "DEFINED_PORT", "NONE"}; + /// BackendTest.Feature's names; AS_PACKAGED stands for the rest as the build has them. + private static final String[] FEATURES = {"MANAGEMENT", "TELEMETRY", "MCP", "AS_PACKAGED"}; private final ProcessorContext ctx; private final Set mainClasses; @@ -131,6 +144,17 @@ private static final class Spec { /// The wiring this class's context starts; set once its key's beans are resolved. String wiringBinary; BackendBeans model; + /// [#SLICE_NONE], [#SLICE_WEB] or [#SLICE_DATA]. + int slice; + /// The application classes a slice wires; empty for the whole application. + final Set sliceScope = new TreeSet(); + /// The annotation as the build reports it, `@WebMvcTest` and so on. + String annotationName = "@BackendTest"; + /// Whether each test method runs in a transaction that is rolled back. + boolean rollback; + /// The production features the application starts with: MANAGEMENT, + /// TELEMETRY and MCP, AS_PACKAGED already expanded. + final Set features = new TreeSet(); } private BackendTestGenerator(ProcessorContext ctx, Set mainClasses, @@ -232,12 +256,46 @@ private Spec spec(AnnotatedClass cls) { Spec spec = new Spec(); spec.test = cls; AnnotatedClass declaring = backendTestOf(cls); - AnnotationValues a = declaring.getClassAnnotation(BACKEND_TEST); - Object env = a.get("webEnvironment"); - String envName = BackendBeans.enumName(env, "MOCK"); - for (int i = 0; i < WEB_ENVIRONMENTS.length; i++) { - if (WEB_ENVIRONMENTS[i].equals(envName)) { - spec.webEnvironment = i; + String descriptor = testAnnotation(declaring); + int carried = 0; + for (String t : TEST_ANNOTATIONS) { + if (declaring.getClassAnnotation(t) != null) { + carried++; + } + } + if (carried > 1) { + ctx.error(declaring, declaring.getSourceName() + " carries more than one of " + + "@BackendTest, @WebMvcTest and @DataJpaTest; a test class runs against " + + "one application."); + return null; + } + AnnotationValues a = declaring.getClassAnnotation(descriptor); + if (BACKEND_TEST.equals(descriptor)) { + Object env = a.get("webEnvironment"); + String envName = BackendBeans.enumName(env, "MOCK"); + for (int i = 0; i < WEB_ENVIRONMENTS.length; i++) { + if (WEB_ENVIRONMENTS[i].equals(envName)) { + spec.webEnvironment = i; + } + } + if (!features(spec, a.get("features"))) { + return null; + } + } else if (WEB_MVC_TEST.equals(descriptor)) { + spec.slice = SLICE_WEB; + spec.annotationName = "@WebMvcTest"; + spec.webEnvironment = 0; + if (!webSlice(spec, cls, a)) { + return null; + } + } else { + spec.slice = SLICE_DATA; + spec.annotationName = "@DataJpaTest"; + // NONE: the test calls the repositories itself, and makes no requests. + spec.webEnvironment = 3; + spec.rollback = a.getBoolOrDefault("rollback", true); + if (!includes(spec, cls, a.get("includes"))) { + return null; } } spec.profile = a.getStringOrDefault("profile", "test").trim(); @@ -247,13 +305,29 @@ private Spec spec(AnnotatedClass cls) { String p = String.valueOf(o); int eq = p.indexOf('='); if (eq <= 0) { - ctx.error(cls, "@BackendTest property \"" + p + "\" is not key=value."); + ctx.error(cls, spec.annotationName + " property \"" + p + + "\" is not key=value."); return null; } spec.properties.add(p.substring(0, eq).trim()); spec.properties.add(p.substring(eq + 1).trim()); } } + if (spec.slice == SLICE_DATA) { + // The declared database, in the top layer with the test's own + // settings, so neither the module's settings nor the environment's + // DATABASE_URL points a persistence test at a database that outlives it. + String database = a.getStringOrDefault("database", ":memory:").trim(); + if (database.length() == 0) { + ctx.error(cls, "@DataJpaTest(database) is empty; name a database URL, or " + + "leave it out for an in-memory one."); + return null; + } + if (!spec.properties.contains(DATASOURCE_URL)) { + spec.properties.add(DATASOURCE_URL); + spec.properties.add(database); + } + } Object listed = a.get("classes"); if (listed instanceof List) { for (Object o : (List) listed) { @@ -261,7 +335,8 @@ private Spec spec(AnnotatedClass cls) { String internal = ((Type) o).getInternalName(); AnnotatedClass config = ctx.lookup(internal); if (config == null || config.getClassAnnotation(TEST_CONFIGURATION) == null) { - ctx.error(cls, "@BackendTest(classes) lists " + internal.replace('/', '.') + ctx.error(cls, spec.annotationName + "(classes) lists " + + internal.replace('/', '.') + ", which is not a @TestConfiguration of this module's tests."); return null; } @@ -314,6 +389,8 @@ private Spec spec(AnnotatedClass cls) { // A test that injects the database starts one the application alone may // not: sharing a context started without it would hand that test null. key.append('|').append(testInjectsDatabase(spec)); + key.append('|').append(spec.slice).append(spec.sliceScope).append('|') + .append(spec.features).append('|').append(spec.rollback); spec.key = key.toString(); return spec; } @@ -322,13 +399,171 @@ private Spec spec(AnnotatedClass cls) { /// the nearest superclass, since the annotation is inherited -- or null. private AnnotatedClass backendTestOf(AnnotatedClass cls) { for (AnnotatedClass c : hierarchy(cls)) { - if (c.getClassAnnotation(BACKEND_TEST) != null) { + if (testAnnotation(c) != null) { return c; } } return null; } + /// Reads `@BackendTest(features)` into `spec`. + private boolean features(Spec spec, Object listed) { + if (!(listed instanceof List)) { + return true; + } + for (Object o : (List) listed) { + String name = BackendBeans.enumName(o, null); + if (name == null || !java.util.Arrays.asList(FEATURES).contains(name)) { + ctx.error(spec.test, "@BackendTest(features) names " + name + + ", which is not a BackendTest.Feature."); + return false; + } + spec.features.add(name); + } + return true; + } + + /// The features `spec` starts its application with, AS_PACKAGED expanded to + /// what the packaged server's entry point links. + private Set features(Spec spec, BackendSettings settings) { + Set out = new TreeSet(spec.features); + if (out.remove("AS_PACKAGED")) { + if (settings.management) { + out.add("MANAGEMENT"); + } + if (settings.mcp || spec.model.hasTools()) { + out.add("MCP"); + } + if ("true".equals(settings.values.get(BackendSettings.TELEMETRY_KEY)) + || telemetryClass() != null) { + out.add("TELEMETRY"); + } + } + return out; + } + + /// The application class carrying `@OpenTelemetry`, or null. + private AnnotationValues telemetryClass() { + for (String internal : mainClasses) { + AnnotationValues a = mainAnnotation(internal, + RestControllerAnnotationProcessor.OPEN_TELEMETRY); + if (a != null) { + return a; + } + } + return null; + } + + /// The service name the exporters report, as a Java expression. + private String telemetryServiceName() { + AnnotationValues a = telemetryClass(); + String name = a == null ? "" : a.getStringOrDefault("serviceName", "").trim(); + return name.length() == 0 ? "null" : quote(name); + } + + /// An application class's annotation, or null when the class is not one of + /// the main build's. + private AnnotationValues mainAnnotation(String internal, String descriptor) { + AnnotatedClass c = mainClasses.contains(internal) ? ctx.lookup(internal) : null; + return c == null ? null : c.getClassAnnotation(descriptor); + } + + private boolean isController(String internal) { + return mainAnnotation(internal, BackendBeans.REST_CONTROLLER) != null + || mainAnnotation(internal, BackendBeans.MVC_CONTROLLER) != null; + } + + /// Whether `internal` is, or inherits, `type`. + private boolean isA(String internal, String type) { + Set seen = new HashSet(); + List todo = new ArrayList(); + todo.add(internal); + while (!todo.isEmpty()) { + String n = todo.remove(todo.size() - 1); + if (n == null || !seen.add(n)) { + continue; + } + if (n.equals(type)) { + return true; + } + AnnotatedClass c = ctx.lookupWithDependencies(n); + if (c != null) { + todo.add(c.getSuperInternalName()); + todo.addAll(c.getInterfaceInternalNames()); + } + } + return false; + } + + /// The scope of a `@WebMvcTest`: the controllers it names (every one when + /// it names none), the advice and the request hooks, and its includes. + private boolean webSlice(Spec spec, AnnotatedClass cls, AnnotationValues a) { + List named = new ArrayList(); + for (String attr : new String[] {"value", "controllers"}) { + Object listed = a.get(attr); + if (listed instanceof List) { + named.addAll((List) listed); + } + } + for (Object o : named) { + if (!(o instanceof Type)) { + continue; + } + String internal = ((Type) o).getInternalName(); + if (!isController(internal)) { + ctx.error(cls, "@WebMvcTest names " + internal.replace('/', '.') + + ", which is not a @RestController or @Controller of this module."); + return false; + } + spec.sliceScope.add(internal); + } + for (String internal : mainClasses) { + if (named.isEmpty() && isController(internal)) { + spec.sliceScope.add(internal); + } + if (mainAnnotation(internal, BackendExceptionHandlers.CONTROLLER_ADVICE) != null + || mainAnnotation(internal, BackendExceptionHandlers.REST_CONTROLLER_ADVICE) + != null + || isA(internal, BackendWiringWriter.INTERCEPTOR_TYPE) + || isA(internal, BackendWiringWriter.RESOLVER_TYPE)) { + spec.sliceScope.add(internal); + } + } + return includes(spec, cls, a.get("includes")); + } + + /// Adds a slice's `includes` to its scope; each must be an application class. + private boolean includes(Spec spec, AnnotatedClass cls, Object listed) { + if (!(listed instanceof List)) { + return true; + } + for (Object o : (List) listed) { + if (!(o instanceof Type)) { + continue; + } + String internal = ((Type) o).getInternalName(); + if (!mainClasses.contains(internal)) { + ctx.error(cls, spec.annotationName + "(includes) lists " + + internal.replace('/', '.') + ", which is not a class of this module's " + + "application; a test bean belongs in a @TestConfiguration."); + return false; + } + spec.sliceScope.add(internal); + } + return true; + } + + /// The backend test annotation `cls` itself carries, or null. A class + /// carrying two is refused by [#spec]. + private static String testAnnotation(AnnotatedClass cls) { + for (String a : TEST_ANNOTATIONS) { + if (cls.getClassAnnotation(a) != null) { + return a; + } + } + return null; + } + /// The class and its superclasses that the build can see, subclass first. private List hierarchy(AnnotatedClass cls) { List out = new ArrayList(); @@ -385,8 +620,22 @@ private void resolveContexts(List specs) throws ProcessingException { Set wiringNames = new LinkedHashSet(); for (List group : byKey.values()) { Spec first = group.get(0); - Set scope = new LinkedHashSet(mainClasses); + Set scope = new LinkedHashSet(first.slice == SLICE_NONE + ? mainClasses : first.sliceScope); scope.addAll(first.configurations); + BackendBeans whole = null; + String hint = null; + if (first.slice != SLICE_NONE) { + whole = wholeApplication(); + if (whole == null) { + return; + } + hint = "A " + first.annotationName + " wires " + (first.slice == SLICE_WEB + ? "the controllers, their advice and request hooks" : "the repositories") + + " and nothing else: list the class in " + first.annotationName + + "(includes), replace it with a @MockitoBean, or declare it in a " + + "@TestConfiguration."; + } Set weaveable = new LinkedHashSet(first.configurations); for (Spec spec : group) { for (AnnotatedClass c : hierarchy(spec.test)) { @@ -396,7 +645,7 @@ private void resolveContexts(List specs) throws ProcessingException { } } BackendBeans model = BackendBeans.forTests(ctx, scope, first.configurations, - first.mocks, weaveable, entryPackage); + first.mocks, weaveable, entryPackage, whole, hint); if (ctx.hasErrors()) { return; } @@ -434,8 +683,33 @@ private void resolveContexts(List specs) throws ProcessingException { simple = base + n; } String binary = BackendBeans.qualify(pkg, simple); - sources.put(binary, new BackendWiringWriter(model).write(pkg, simple, routers, sockets, - routes, true)); + List sliceRouters = routers; + Map sliceSockets = sockets; + List sliceRoutes = routes; + if (first.slice != SLICE_NONE) { + // Only the routes of the controllers the slice has; a router names + // its controller bean, which a slice that left it out cannot build. + sliceRouters = new ArrayList(); + for (BackendWiringWriter.Router r : routers) { + if (first.sliceScope.contains(r.controllerBinary.replace('.', '/'))) { + sliceRouters.add(r); + } + } + sliceSockets = new LinkedHashMap(); + for (Map.Entry e : sockets.entrySet()) { + if (first.sliceScope.contains(e.getValue().replace('.', '/'))) { + sliceSockets.put(e.getKey(), e.getValue()); + } + } + sliceRoutes = new ArrayList(); + for (String[] r : routes) { + if (first.sliceScope.contains(r[3].replace('.', '/'))) { + sliceRoutes.add(r); + } + } + } + sources.put(binary, new BackendWiringWriter(model).write(pkg, simple, sliceRouters, + sliceSockets, sliceRoutes, true)); for (Spec spec : group) { spec.wiringBinary = binary; spec.model = model; @@ -443,6 +717,21 @@ private void resolveContexts(List specs) throws ProcessingException { } } + /// The whole application, resolved once, for the slot numbers a slice keeps. + private BackendBeans wholeApplication; + + private BackendBeans wholeApplication() throws ProcessingException { + if (wholeApplication == null) { + wholeApplication = BackendBeans.forTests(ctx, new LinkedHashSet(mainClasses), + new TreeSet(), new ArrayList(), new TreeSet(), + entryPackage); + if (ctx.hasErrors()) { + return null; + } + } + return wholeApplication; + } + /// `pkg.Name` to `pkg.NameCn1TestContext`, a nested class's `$` written `_`. /// Generated class -> the test class it was named after, for [#claim]. private final Map generatedNames = new TreeMap(); @@ -542,7 +831,24 @@ private String context(Spec spec) { // named only here: a test application without one links none of it. sb.append(" com.codename1.impl.backend.BackendAccess.get().security(builder);\n"); } + Set features = features(spec, settings); + if (features.contains("TELEMETRY")) { + // The exporters as the generated main installs them, named only by a + // test that asked: one that did not links none of OTLP. + String name = telemetryServiceName(); + sb.append(" builder.tracing(new com.codename1.backend.otel.OtlpTracer(") + .append(name).append("))\n .metrics(new com.codename1.backend.otel." + + "OtlpMetricExporter(").append(name).append("));\n"); + } + if (features.contains("MANAGEMENT")) { + sb.append(" com.codename1.impl.backend.BackendAccess.get().management(builder);\n"); + } + if (features.contains("MCP")) { + sb.append(" com.codename1.impl.backend.BackendAccess.get().mcp(builder, null);\n"); + } sb.append(" }\n\n"); + sb.append(" public boolean rollsBack() {\n return ").append(spec.rollback) + .append(";\n }\n\n"); sb.append(" public String[] securityContext(String method) {\n"); Set described = new LinkedHashSet(); for (AnnotatedClass c : methodHierarchy(spec.test)) { @@ -597,6 +903,14 @@ private String context(Spec spec) { /// EntityManager -- which starts one even when the application has nothing /// that needs it: the field would otherwise be filled with null instead of the /// development profile's in-memory database. + /// Whether a test field of type `internal` is filled with a repository: a + /// `@Repository` interface no bean of the application implements. + private boolean repositoryField(String internal) { + AnnotatedClass c = ctx.lookupWithDependencies(internal); + return c != null && c.isInterface() + && c.getClassAnnotation(RepositoryGenerator.REPOSITORY) != null; + } + private boolean testInjectsDatabase(Spec spec) { for (AnnotatedClass c : hierarchy(spec.test)) { for (FieldInfo f : c.getFields()) { @@ -606,7 +920,8 @@ private boolean testInjectsDatabase(Spec spec) { } Type t = Type.getType(f.getDescriptor()); if (t.getSort() == Type.OBJECT && (BackendBeans.DATASOURCE_TYPE.equals(t.getInternalName()) - || BackendBeans.ENTITIES_TYPE.equals(t.getInternalName()))) { + || BackendBeans.ENTITIES_TYPE.equals(t.getInternalName()) + || repositoryField(t.getInternalName()))) { return true; } } @@ -658,6 +973,21 @@ private String injection(Spec spec, AnnotatedClass owner, FieldInfo f) { } } AnnotationValues qualifier = f.getAnnotation(BackendBeans.QUALIFIER); + if (!mock && qualifier == null && repositoryField(type)) { + boolean implemented = false; + for (BackendBeans.Bean b : spec.model.beans) { + implemented |= b.types.contains(type); + } + if (!implemented) { + // As the application's own beans get one: the factory the ORM's + // bootstrap registered, over a session that forwards to the + // calling thread's transaction -- the test method's, in a + // @DataJpaTest. + return "com.codename1.orm.repository.Repositories.create(" + source + + ".class, new com.codename1.impl.backend.TransactionSession(" + + "environment.backend().getEntityManager()))"; + } + } String wanted = qualifier == null ? null : qualifier.getStringOrDefault("value", ""); List candidates = new ArrayList(); for (BackendBeans.Bean b : spec.model.beans) { @@ -844,7 +1174,19 @@ private boolean runner(AnnotatedClass cls, Spec spec) { for (MethodInfo b : beforeEach) { sb.append(" ").append(call(owners.get(b), b, pkg, "test")).append(";\n"); } - sb.append(" ").append(call(owners.get(m), m, pkg, "test")).append(";\n"); + if (spec != null && spec.rollback) { + // The test method alone in the transaction, as the JUnit + // extension runs it: the @BeforeEach methods above and the + // @AfterEach ones below see the database as it was. + sb.append(" final ").append(testType).append(" body = test;\n"); + sb.append(" com.codename1.impl.backend.test.TestTransactions.rollingBack(" + + "new com.codename1.impl.backend.test.TestTransactions.Body() {\n" + + " public void run() throws Throwable {\n" + + " ").append(call(owners.get(m), m, pkg, "body")) + .append(";\n }\n });\n"); + } else { + sb.append(" ").append(call(owners.get(m), m, pkg, "test")).append(";\n"); + } sb.append(" } catch (Throwable err) {\n").append(RETHROW_UNRECOVERABLE) .append(" failure = err;\n }\n"); if (who != null) { @@ -1052,6 +1394,30 @@ private AnnotationValues composedOf(String descriptor, String annotation, Set libraryInterfaces = new java.util.HashMap(); + + /// A test class's interface: one of the module's, or one a library ships. + /// The pass's dependency index is empty unless another processor asked for + /// one, so a library's is read here, once. + private AnnotatedClass interfaceClass(String name) { + AnnotatedClass own = ctx.lookup(name); + if (own != null) { + return own; + } + if (!libraryInterfaces.containsKey(name)) { + AnnotatedClass read = null; + try { + read = com.codename1.maven.annotations.DependencyClasses.read( + ctx.getCompileClasspath(), name); + } catch (ProcessingException unreadable) { + read = null; + } + libraryInterfaces.put(name, read); + } + return libraryInterfaces.get(name); + } + private List methodHierarchy(AnnotatedClass cls) { List out = new ArrayList(); Set visited = new LinkedHashSet(); @@ -1060,7 +1426,10 @@ private List methodHierarchy(AnnotatedClass cls) { java.util.ArrayDeque pending = new java.util.ArrayDeque(c.getInterfaceInternalNames()); while (!pending.isEmpty() && visited.size() < 64) { String name = pending.removeFirst(); - AnnotatedClass i = visited.add(name) ? ctx.lookup(name) : null; + // From the dependencies too: a contract a test library ships + // as an interface of default @Test methods runs compiled as it + // runs under JUnit. + AnnotatedClass i = visited.add(name) ? interfaceClass(name) : null; if (i != null) { out.add(i); pending.addAll(i.getInterfaceInternalNames()); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java index d44e1196808..0c28aaf03b2 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java @@ -114,7 +114,7 @@ public static int process(List mainClassDirs, File testClasses, List throw new BuildExecutionException("Could not scan the compiled classes: " + err.getMessage(), err); } - if (!compiled && !mentions(tests, BackendTestGenerator.BACKEND_TEST)) { + if (!compiled && !mentionsAny(tests, BackendTestGenerator.TEST_ANNOTATIONS)) { return 0; } Map index = new LinkedHashMap(main); @@ -140,6 +140,15 @@ public static int process(List mainClassDirs, File testClasses, List return count; } + private static boolean mentionsAny(Map classes, String[] descriptors) { + for (String d : descriptors) { + if (mentions(classes, d)) { + return true; + } + } + return false; + } + private static boolean mentions(Map classes, String descriptor) { for (AnnotatedClass cls : classes.values()) { if (cls.getAllAnnotationDescriptors().contains(descriptor)) { diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java index 118b51ff467..00d0ed313e0 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java @@ -951,6 +951,206 @@ public void mvcTestContextServesCompiledViewsAndEmbeddedAssets() throws Exceptio invoke(test,"works"); } + /// The application with a component no slice may build: its constructor + /// throws, so a context that wires it fails to start. + private static Map applicationWithAService() { + Map s = application(); + s.put("com.example.Mailer", MAIN + "@Component public class Mailer {\n" + + " public Mailer() { throw new IllegalStateException(\"no mail server in a test\"); }\n" + + "}\n"); + return s; + } + + private static final String SLICE_IMPORTS = "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "import static com.codename1.backend.test.MockMvcRequestBuilders.*;\n" + + "import static com.codename1.backend.test.MockMvcResultMatchers.*;\n"; + + @Test + public void aWebSliceWiresTheControllerAndNothingElse() throws Exception { + File classes = mainBuild(applicationWithAService()); + Map t = new LinkedHashMap(); + t.put("com.example.ApiSliceTest", SLICE_IMPORTS + + "@WebMvcTest(controllers = Api.class, includes = {Greeter.class, SystemClock.class})\n" + + "public class ApiSliceTest {\n" + + " @Autowired private MockMvc mvc;\n" + + " @org.junit.jupiter.api.Test\n" + + " void greets() throws Exception {\n" + + " mvc.perform(get(\"/greet/{name}\", \"Ada\")).andExpect(content().string(\"Hello, Ada\"));\n" + + " }\n" + + "}\n"); + File tests = testBuild(classes, t); + URLClassLoader loader = new URLClassLoader(new URL[] {tests.toURI().toURL(), + classes.toURI().toURL()}, getClass().getClassLoader()); + try { + TestContext context = (TestContext) loader.loadClass( + "com.example.ApiSliceTestCn1TestContext").newInstance(); + assertEquals(0, context.webEnvironment()); + assertFalse(context.requiresDataSource()); + assertFalse(context.rollsBack()); + // Starts although Mailer cannot be built: the slice never names it. + TestEnvironment env = TestContexts.acquire(context); + Object test = loader.loadClass("com.example.ApiSliceTest").newInstance(); + context.inject(test, env); + invoke(test, "greets"); + } finally { + loader.close(); + } + } + + @Test + public void aWebSliceMissingADependencyFailsTheBuildWithTheWayOut() throws Exception { + File classes = mainBuild(applicationWithAService()); + Map t = new LinkedHashMap(); + t.put("com.example.ThinSliceTest", SLICE_IMPORTS + + "@WebMvcTest(Api.class)\n" + + "public class ThinSliceTest {\n" + + " @Autowired private MockMvc mvc;\n" + + "}\n"); + try { + testBuild(classes, t); + fail("a slice without the controller's dependencies was accepted"); + } catch (BuildFailureException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("@WebMvcTest(includes)")); + assertTrue(expected.getMessage(), expected.getMessage().contains("@MockitoBean")); + } + } + + @Test + public void aWebSliceNamingANonControllerIsABuildError() throws Exception { + File classes = mainBuild(application()); + Map t = new LinkedHashMap(); + t.put("com.example.WrongSliceTest", SLICE_IMPORTS + + "@WebMvcTest(Greeter.class)\n" + + "public class WrongSliceTest {\n" + + "}\n"); + try { + testBuild(classes, t); + fail("a slice of a component was accepted"); + } catch (BuildFailureException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains( + "com.example.Greeter, which is not a @RestController")); + } + } + + @Test + public void aDataSliceRollsEachTestBackAndUsesItsDeclaredDatabase() throws Throwable { + File classes = mainBuild(applicationWithAService()); + Map t = new LinkedHashMap(); + t.put("com.example.StoreTest", SLICE_IMPORTS + + "@DataJpaTest\n" + + "public class StoreTest {\n" + + " @Autowired com.codename1.backend.DataSource db;\n" + + " @org.junit.jupiter.api.Test\n" + + " void writes() throws Exception {\n" + + " db.execute(\"INSERT INTO notes(body) VALUES (?)\", new Object[] {\"draft\"});\n" + + " if (db.query(\"SELECT body FROM notes\", new Object[0]).size() != 1) {\n" + + " throw new AssertionError(\"the write is not visible inside its test\");\n" + + " }\n" + + " }\n" + + "}\n"); + File tests = testBuild(classes, t, true); + URLClassLoader loader = new URLClassLoader(new URL[] {tests.toURI().toURL(), + classes.toURI().toURL()}, getClass().getClassLoader()); + try { + TestContext context = (TestContext) loader.loadClass( + "com.example.StoreTestCn1TestContext").newInstance(); + assertTrue(context.rollsBack()); + assertEquals(3, context.webEnvironment()); + assertTrue(Arrays.asList(context.properties()).contains(":memory:")); + TestEnvironment env = TestContexts.acquire(context); + final Object test = loader.loadClass("com.example.StoreTest").newInstance(); + context.inject(test, env); + com.codename1.backend.DataSource db = env.backend().getDataSource(); + db.execute("CREATE TABLE notes(body TEXT)", new Object[0]); + for (int i = 0; i < 2; i++) { + com.codename1.impl.backend.test.TestTransactions.rollingBack( + new com.codename1.impl.backend.test.TestTransactions.Body() { + @Override + public void run() throws Throwable { + invoke(test, "writes"); + } + }); + } + assertEquals("a rolled back test left its row", 0, + db.query("SELECT body FROM notes", new Object[0]).size()); + // The compiled runner wraps the same method the same way. + java.lang.reflect.Field failed = com.codename1.impl.backend.test.TestRun.class + .getDeclaredField("failed"); + failed.setAccessible(true); + int failuresBefore = failed.getInt(null); + loader.loadClass("com.example.StoreTestCn1TestRunner").getMethod("run").invoke(null); + assertEquals("the compiled run of the test failed", failuresBefore, failed.getInt(null)); + assertEquals("the compiled run left its row", 0, + db.query("SELECT body FROM notes", new Object[0]).size()); + } finally { + loader.close(); + } + } + + @Test + public void featuresAreWiredOnlyWhenATestAsksForThem() throws Exception { + File classes = mainBuild(application()); + Map t = new LinkedHashMap(); + String props = "properties = {\"cn1.management.token=s3cret\",\n" + + " \"cn1.otel.endpoint=http://127.0.0.1:9/\"}"; + t.put("com.example.PlainFeaturesTest", SLICE_IMPORTS + + "@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT, " + props + ")\n" + + "public class PlainFeaturesTest { }\n"); + t.put("com.example.ManagedTest", SLICE_IMPORTS + + "@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT,\n" + + " features = {BackendTest.Feature.MANAGEMENT, BackendTest.Feature.TELEMETRY},\n" + + " " + props + ")\n" + + "public class ManagedTest implements ManagementContract {\n" + + " @Autowired TestRestTemplate rest;\n" + + " public TestRestTemplate managementClient() { return rest; }\n" + + " public String managementToken() { return \"s3cret\"; }\n" + + "}\n"); + File tests = testBuild(classes, t, true); + URLClassLoader loader = new URLClassLoader(new URL[] {tests.toURI().toURL(), + classes.toURI().toURL()}, getClass().getClassLoader()); + try { + TestEnvironment plain = TestContexts.acquire((TestContext) loader.loadClass( + "com.example.PlainFeaturesTestCn1TestContext").newInstance()); + assertEquals("a test that did not ask has management endpoints", 404, + status(plain.port(), "/manage/schema", "s3cret")); + assertNull("a test that did not ask has an exporter", plain.backend().getTracer()); + TestContext context = (TestContext) loader.loadClass( + "com.example.ManagedTestCn1TestContext").newInstance(); + TestEnvironment env = TestContexts.acquire(context); + assertTrue(String.valueOf(env.backend().getTracer()), env.backend().getTracer() + instanceof com.codename1.backend.otel.OtlpTracer); + assertEquals(401, status(env.port(), "/manage/schema", null)); + assertEquals(401, status(env.port(), "/manage/schema", "wrong")); + assertEquals(200, status(env.port(), "/manage/schema", "s3cret")); + assertEquals(200, status(env.port(), "/manage/health/liveness", null)); + // The shared contract, through the compiled runner: its four tests + // are the interface's default methods. + java.lang.reflect.Field failed = com.codename1.impl.backend.test.TestRun.class + .getDeclaredField("failed"); + failed.setAccessible(true); + int failuresBefore = failed.getInt(null); + loader.loadClass("com.example.ManagedTestCn1TestRunner").getMethod("run").invoke(null); + assertEquals("the management contract failed", failuresBefore, failed.getInt(null)); + } finally { + loader.close(); + } + } + + private static int status(int port, String path, String token) throws Exception { + java.net.HttpURLConnection c = (java.net.HttpURLConnection) new URL( + "http://127.0.0.1:" + port + path).openConnection(); + if (token != null) { + c.setRequestProperty("Authorization", "Bearer " + token); + } + try { + return c.getResponseCode(); + } finally { + c.disconnect(); + } + } + private static void invoke(Object test, String name) throws Exception { java.lang.reflect.Method m = test.getClass().getDeclaredMethod(name); m.setAccessible(true); diff --git a/vm/backend/dashboards/backend-overview.json b/vm/backend/dashboards/backend-overview.json new file mode 100644 index 00000000000..1a78504d261 --- /dev/null +++ b/vm/backend/dashboards/backend-overview.json @@ -0,0 +1,483 @@ +{ + "title": "Codename One backend", + "uid": "cn1-backend-overview", + "schemaVersion": 39, + "version": 1, + "editable": true, + "tags": [ + "codenameone", + "backend" + ], + "time": { + "from": "now-1h", + "to": "now" + }, + "refresh": "30s", + "description": "Built only from metrics every Codename One backend reports, on the JVM and as a packaged native server. See MetricSchema.", + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus", + "label": "Data source" + }, + { + "name": "job", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "query": "label_values(process_uptime, job)", + "refresh": 2, + "includeAll": true, + "multi": true, + "current": { + "text": "All", + "value": "$__all" + } + }, + { + "name": "instance", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "query": "label_values(process_uptime{job=~\"$job\"}, instance)", + "refresh": 2, + "includeAll": true, + "multi": true, + "current": { + "text": "All", + "value": "$__all" + } + } + ] + }, + "panels": [ + { + "type": "row", + "title": "HTTP", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 1 + }, + "panels": [], + "id": 1 + }, + { + "type": "timeseries", + "title": "Requests per second, by route", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 1, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (http_route, http_request_method) (rate(http_server_request_duration_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{http_request_method}} {{http_route}}" + } + ], + "id": 2 + }, + { + "type": "timeseries", + "title": "Latency p95, by route", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 1, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "legendFormat": "{{http_route}}" + } + ], + "id": 3 + }, + { + "type": "timeseries", + "title": "Server errors (5xx) per second", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 9, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (http_route) (rate(http_server_request_duration_count{job=~\"$job\", instance=~\"$instance\", http_response_status_code=~\"5..\"}[$__rate_interval]))", + "legendFormat": "{{http_route}}" + } + ], + "id": 4 + }, + { + "type": "timeseries", + "title": "Requests in flight and connections", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 9, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(http_server_active_requests{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "active requests" + }, + { + "refId": "B", + "expr": "sum(http_server_open_connections{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "open connections" + }, + { + "refId": "C", + "expr": "sum(cn1_server_websocket_connections{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "websockets" + }, + { + "refId": "D", + "expr": "sum(rate(cn1_server_connections_refused{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "refused / s" + } + ], + "id": 5 + }, + { + "type": "row", + "title": "Database pool", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 17, + "w": 24, + "h": 1 + }, + "panels": [], + "id": 6 + }, + { + "type": "timeseries", + "title": "Pool utilization", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 18, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(db_client_connection_count{job=~\"$job\", instance=~\"$instance\"} - db_client_connection_idle{job=~\"$job\", instance=~\"$instance\"}) / sum(db_client_connection_max{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "in use / max" + } + ], + "id": 7 + }, + { + "type": "timeseries", + "title": "Borrowers waiting and timeouts", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 18, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(db_client_connection_pending_requests{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "waiting" + }, + { + "refId": "B", + "expr": "sum(increase(db_client_connection_timeouts{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "timeouts" + } + ], + "id": 8 + }, + { + "type": "row", + "title": "Executors and jobs", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 26, + "w": 24, + "h": 1 + }, + "panels": [], + "id": 9 + }, + { + "type": "timeseries", + "title": "Executor queue and active tasks", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 27, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (cn1_executor) (cn1_task_queue_depth{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "queued {{cn1_executor}}" + }, + { + "refId": "B", + "expr": "sum by (cn1_executor) (cn1_task_active{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "active {{cn1_executor}}" + } + ], + "id": 10 + }, + { + "type": "timeseries", + "title": "Scheduled job runs, by outcome", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 27, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (cn1_job, cn1_outcome) (increase(cn1_scheduler_run_duration_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "legendFormat": "{{cn1_job}} {{cn1_outcome}}" + } + ], + "id": 11 + }, + { + "type": "row", + "title": "Process", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 35, + "w": 24, + "h": 1 + }, + "panels": [], + "id": 12 + }, + { + "type": "timeseries", + "title": "Heap", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 36, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(process_runtime_memory_used{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "used" + }, + { + "refId": "B", + "expr": "sum(process_runtime_memory_committed{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "committed" + } + ], + "id": 13 + }, + { + "type": "timeseries", + "title": "Uptime", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 36, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + } + }, + "targets": [ + { + "refId": "A", + "expr": "max(process_uptime{job=~\"$job\", instance=~\"$instance\"})", + "legendFormat": "uptime" + } + ], + "id": 14 + } + ] +} diff --git a/vm/backend/demo/opscheck/com/demo/OpsServer.java b/vm/backend/demo/opscheck/com/demo/OpsServer.java new file mode 100644 index 00000000000..b77ab67f2ae --- /dev/null +++ b/vm/backend/demo/opscheck/com/demo/OpsServer.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.demo; + +import com.codename1.backend.Backend; +import com.codename1.backend.HttpServer; +import com.codename1.backend.otel.OtlpMetricExporter; +import com.codename1.impl.backend.BackendAccess; + +/// A server with management and the OTLP metric exporter on, for +/// BackendOperationsTest to hold the PACKAGED runtime to the operational +/// contract: the probes, the endpoint authentication, the metric schema, the +/// Prometheus scrape the sample dashboard reads, and an exporter that flushes +/// when the process is told to stop. +/// +/// Configured from the environment, as a deployment is: PORT, +/// CN1_DATASOURCE_URL, CN1_MANAGEMENT_ENABLED, CN1_MANAGEMENT_TOKEN and the +/// OTEL_* variables. +public class OpsServer { + public static void main(String[] args) throws Exception { + Backend.Builder builder = Backend.builder() + .metrics(new OtlpMetricExporter("opscheck")) + .requiresDataSource(); + // As the generated entry point links it: not a Builder method an + // application calls. + BackendAccess.get().management(builder); + builder.handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) { + String path = request.getTarget(); + int query = path.indexOf('?'); + if (query >= 0) { + path = path.substring(0, query); + } + if ("/hello".equals(path)) { + return HttpServer.Response.text(200, "hi"); + } + if ("/fail".equals(path)) { + return HttpServer.Response.text(503, "no"); + } + return null; + } + }).run(); + } +} diff --git a/vm/backend/impl/javase/com/codename1/impl/backend/RuntimeGauges.java b/vm/backend/impl/javase/com/codename1/impl/backend/RuntimeGauges.java new file mode 100644 index 00000000000..490cb63c814 --- /dev/null +++ b/vm/backend/impl/javase/com/codename1/impl/backend/RuntimeGauges.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.util.List; + +/// What the runtime itself can report about the process, for the built-in +/// gauges: the Java SE arm's half, which asks the JVM's management beans. +/// +/// Every reading answers NaN when the runtime has no such figure, and a gauge +/// whose reading is NaN reports no point at all. That is how a dashboard tells a +/// figure that is not measured here from one that is measured and zero; see +/// [com.codename1.backend.metrics.MetricSchema]. +public final class RuntimeGauges { + private RuntimeGauges() { + } + + /// The runtime's name in [com.codename1.backend.metrics.MetricSchema]: + /// `jvm` here, `native` in a packaged server. + public static String runtime() { + return "jvm"; + } + + /// The most heap the process may use, in bytes, or NaN when it is unbounded. + public static double heapLimit() { + long max = Runtime.getRuntime().maxMemory(); + return max == Long.MAX_VALUE || max <= 0 ? Double.NaN : max; + } + + /// Live platform threads. + public static double threadCount() { + return ManagementFactory.getThreadMXBean().getThreadCount(); + } + + /// Classes currently loaded. + public static double classCount() { + return ManagementFactory.getClassLoadingMXBean().getLoadedClassCount(); + } + + /// Processors the runtime may schedule threads on. + public static double cpuCount() { + return Runtime.getRuntime().availableProcessors(); + } + + /// Garbage collections since start, over every collector. + public static double gcCount() { + return gcSum(false); + } + + /// Milliseconds spent collecting since start, over every collector. + public static double gcTime() { + return gcSum(true); + } + + private static double gcSum(boolean time) { + List beans = ManagementFactory.getGarbageCollectorMXBeans(); + double sum = 0; + boolean any = false; + for (GarbageCollectorMXBean bean : beans) { + long v = time ? bean.getCollectionTime() : bean.getCollectionCount(); + // -1 is a collector that does not report the figure. + if (v >= 0) { + sum += v; + any = true; + } + } + return any ? sum : Double.NaN; + } +} diff --git a/vm/backend/impl/parparvm/com/codename1/impl/backend/RuntimeGauges.java b/vm/backend/impl/parparvm/com/codename1/impl/backend/RuntimeGauges.java new file mode 100644 index 00000000000..7e02e34a427 --- /dev/null +++ b/vm/backend/impl/parparvm/com/codename1/impl/backend/RuntimeGauges.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +/// What the runtime itself can report about the process: the packaged server's +/// half. ParparVM has no heap limit to report -- `Runtime.maxMemory()` answers +/// the total -- no platform thread or class registry, and a collector that runs +/// concurrently rather than in counted, timed pauses. Each of those reads NaN, +/// so its gauge is never registered and the figure is listed as unavailable, +/// never reported as zero; see [com.codename1.backend.metrics.MetricSchema]. +public final class RuntimeGauges { + private RuntimeGauges() { + } + + public static String runtime() { + return "native"; + } + + public static double heapLimit() { + return Double.NaN; + } + + public static double threadCount() { + return Double.NaN; + } + + public static double classCount() { + return Double.NaN; + } + + /// NaN rather than `availableProcessors()`, which answers a fixed 1 here. + public static double cpuCount() { + return Double.NaN; + } + + public static double gcCount() { + return Double.NaN; + } + + public static double gcTime() { + return Double.NaN; + } +} diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index fa46172d3ad..044abcd8172 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -93,6 +93,12 @@ public interface Work { private boolean closed; /// Slots reserved by borrowers opening a connection outside the monitor. private int opening; + /// Borrowers waiting for a connection right now, and borrows that gave up + /// waiting since the pool opened. The pool's saturation, which the open and + /// idle counts cannot show: a full pool nobody waits for is busy, and one with + /// a queue is too small. + private int pending; + private long timeouts; private DataSource(String url, String describedAs, int maxSize, int busyTimeoutMillis, long borrowTimeoutMillis, Dialect dialect, boolean owns) { @@ -258,9 +264,16 @@ Database borrowFromPool() throws IOException { : AsyncTask.deadline(System.currentTimeMillis(), borrowTimeoutMillis); boolean virtual = VirtualThread.isVirtual(); long nap = 1; + // A virtual thread naps OUTSIDE the monitor, so its place in the queue is + // given back when it comes back in rather than around the nap. + boolean napping = false; while (true) { boolean reserved = false; synchronized (this) { + if (napping) { + napping = false; + pending--; + } // Checked before the idle list rather than only when it is // empty: close() can run while a borrower still holds a // connection, and that borrower's finally releases afterwards, @@ -296,11 +309,13 @@ Database borrowFromPool() throws IOException { } long left = deadline - System.currentTimeMillis(); if (left <= 0) { + timeouts++; throw new IOException("No database connection became free within " + borrowTimeoutMillis + "ms; the pool holds " + maxSize + " and they are all in use. Either the work holding them is " + "too slow or " + Config.DATASOURCE_POOL_SIZE + " is too low."); } + pending++; if (!virtual) { try { wait(deadline == Long.MAX_VALUE ? 0 : left); @@ -308,9 +323,12 @@ Database borrowFromPool() throws IOException { Thread.currentThread().interrupt(); throw new IOException("Interrupted while waiting for a database " + "connection", err); + } finally { + pending--; } continue; } + napping = true; } } if (reserved) { @@ -318,7 +336,14 @@ Database borrowFromPool() throws IOException { } // A virtual thread, outside the monitor: give the host back. long now = System.currentTimeMillis(); - HttpServer.napUntil(Math.min(deadline, now + nap)); + try { + HttpServer.napUntil(Math.min(deadline, now + nap)); + } catch (RuntimeException err) { + synchronized (this) { + pending--; + } + throw err; + } nap = Math.min(20, nap * 2); } } @@ -541,6 +566,18 @@ public synchronized int getIdleCount() { return idle.size(); } + /// How many borrowers are waiting for a connection right now, because every + /// one the pool may hold is in use. + public synchronized int getPendingCount() { + return pending; + } + + /// How many borrows gave up since the pool opened, because no connection + /// became free within the borrow timeout. + public synchronized long getTimeoutCount() { + return timeouts; + } + /// Closes every connection. Borrowed ones are closed as they come back. public synchronized void close() { closed = true; diff --git a/vm/backend/src/com/codename1/backend/metrics/Gauge.java b/vm/backend/src/com/codename1/backend/metrics/Gauge.java index 1495f45e3cc..af467b20d1b 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Gauge.java +++ b/vm/backend/src/com/codename1/backend/metrics/Gauge.java @@ -95,6 +95,11 @@ public List points() { return new java.util.ArrayList(); } } - return single(point(null, read())); + double value = read(); + // No point rather than NaN: a reading that failed, or a figure the + // server does not have (a pool gauge with no database), is absent from + // every view. NaN in a Prometheus scrape or an OTLP point reads as a + // measurement, and a dashboard cannot tell it from one. + return Double.isNaN(value) ? new java.util.ArrayList() : single(point(null, value)); } } diff --git a/vm/backend/src/com/codename1/backend/metrics/MetricSchema.java b/vm/backend/src/com/codename1/backend/metrics/MetricSchema.java new file mode 100644 index 00000000000..c1d8a04d5dc --- /dev/null +++ b/vm/backend/src/com/codename1/backend/metrics/MetricSchema.java @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.metrics; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The published schema of the metrics the server records about itself: each +/// one's name, kind, unit, attributes and lifecycle, the runtimes that measure +/// it, and the Micrometer meter a Spring Boot dashboard reads for the same +/// figure. The names, units and attributes listed here are stable; a dashboard +/// or alert written against them keeps working across releases and across the +/// JVM and the packaged native server. +/// +/// A figure a runtime cannot measure is never reported as zero there. Its +/// instrument is not registered, or -- for a pool gauge on a server with no +/// database -- reports no point, so it is absent from every view, and +/// [#describe] lists it as unavailable with the reason. The Micrometer meters +/// neither runtime has an equivalent for are listed by [#unmeasured]. +/// +/// `GET /manage/schema` serves [#describe] for the running server. +public final class MetricSchema { + /// Measured by every runtime. + public static final String ALWAYS = "always"; + /// Measured when the server has a database pool. + public static final String DATABASE = "database"; + /// Measured on the JVM only; a packaged native server has no equivalent. + public static final String JVM = "jvm"; + + /// A value that only grows while the process runs, and starts again from + /// zero when it restarts. Rate and increase functions apply. + public static final String CUMULATIVE = "cumulative"; + /// A value read when the metrics are collected, which may rise or fall. + public static final String INSTANT = "instant"; + + /// One instrument of the schema. + public static final class Entry { + private final String name; + private final int kind; + private final String unit; + private final String[] attributes; + private final String temporality; + private final String availability; + private final String micrometer; + private final String description; + + Entry(String name, int kind, String unit, String[] attributes, String temporality, + String availability, String micrometer, String description) { + this.name = name; + this.kind = kind; + this.unit = unit; + this.attributes = attributes; + this.temporality = temporality; + this.availability = availability; + this.micrometer = micrometer; + this.description = description; + } + + /// The instrument's name, as the OTLP exporter and the JSON view send it. + public String getName() { + return name; + } + + /// One of the [Instrument] kinds. + public int getKind() { + return kind; + } + + /// The UCUM unit, as OpenTelemetry writes it: `ms`, `s`, `By`, or a + /// `{curly}` annotation for a count. + public String getUnit() { + return unit; + } + + /// The attribute keys a point may carry, in order. + public String[] getAttributes() { + return attributes.clone(); + } + + /// [#CUMULATIVE] or [#INSTANT]. + public String getTemporality() { + return temporality; + } + + /// [#ALWAYS], [#DATABASE] or [#JVM]. + public String getAvailability() { + return availability; + } + + /// The Micrometer meter a Spring Boot application reports the same + /// figure under, or null when it has none. + public String getMicrometer() { + return micrometer; + } + + public String getDescription() { + return description; + } + + /// The name the Prometheus view exposes it under: dots become + /// underscores, and a counter gains `_total`. A histogram's series add + /// `_bucket`, `_sum` and `_count` to it. + public String getPrometheusName() { + String base = Metrics.promName(name); + return kind == Instrument.COUNTER ? base + "_total" : base; + } + } + + private static final String[] NONE = new String[0]; + private static final List ENTRIES; + private static final List UNMEASURED; + + static { + List e = new ArrayList(); + e.add(new Entry("http.server.request.duration", Instrument.HISTOGRAM, "ms", + new String[] {"http.route", "http.request.method", "http.response.status_code"}, + CUMULATIVE, ALWAYS, "http.server.requests", + "Duration of HTTP server requests, by route template, method and status")); + e.add(gauge("http.server.active_requests", "{request}", INSTANT, ALWAYS, + "http.server.requests.active", "Requests being served")); + e.add(gauge("http.server.open_connections", "{connection}", INSTANT, ALWAYS, + "tomcat.connections.current", "Open client connections")); + e.add(gauge("cn1.server.websocket_connections", "{connection}", INSTANT, ALWAYS, + null, "Open websocket connections")); + e.add(gauge("cn1.server.requests_served", "{request}", CUMULATIVE, ALWAYS, null, + "Requests answered since start")); + e.add(gauge("cn1.server.connections_refused", "{connection}", CUMULATIVE, ALWAYS, + null, "Connections refused for being over the limit")); + e.add(gauge("db.client.connection.count", "{connection}", INSTANT, DATABASE, + "hikaricp.connections", "Open database connections, borrowed or idle")); + e.add(gauge("db.client.connection.idle", "{connection}", INSTANT, DATABASE, + "hikaricp.connections.idle", "Idle database connections")); + e.add(gauge("db.client.connection.max", "{connection}", INSTANT, DATABASE, + "hikaricp.connections.max", "Connections the pool may hold")); + e.add(gauge("db.client.connection.pending_requests", "{request}", INSTANT, DATABASE, + "hikaricp.connections.pending", "Borrowers waiting for a connection")); + e.add(gauge("db.client.connection.timeouts", "{timeout}", CUMULATIVE, DATABASE, + "hikaricp.connections.timeout", + "Borrows that gave up waiting for a connection since start")); + e.add(executor("cn1.task.queue_depth", "{task}", INSTANT, "executor.queued", + "Tasks waiting for a thread")); + e.add(executor("cn1.task.active", "{task}", INSTANT, "executor.active", + "Tasks running")); + e.add(executor("cn1.task.completed", "{task}", CUMULATIVE, "executor.completed", + "Tasks finished since start")); + e.add(executor("cn1.task.pool_size", "{thread}", INSTANT, "executor.pool.size", + "Threads the executor runs tasks on")); + e.add(new Entry("cn1.scheduler.run.duration", Instrument.HISTOGRAM, "ms", + new String[] {"cn1.job", "cn1.outcome"}, CUMULATIVE, ALWAYS, + "tasks.scheduled.execution", + "Duration of scheduled job runs, by job and outcome (success or failure)")); + e.add(gauge("process.runtime.memory.used", "By", INSTANT, ALWAYS, "jvm.memory.used", + "Heap in use")); + e.add(gauge("process.runtime.memory.committed", "By", INSTANT, ALWAYS, + "jvm.memory.committed", "Heap the runtime holds from the system")); + e.add(gauge("process.runtime.memory.limit", "By", INSTANT, JVM, "jvm.memory.max", + "The most heap the process may use")); + e.add(gauge("process.uptime", "s", INSTANT, ALWAYS, "process.uptime", + "Seconds since the process started")); + e.add(gauge("jvm.thread.count", "{thread}", INSTANT, JVM, "jvm.threads.live", + "Live platform threads")); + e.add(gauge("jvm.class.count", "{class}", INSTANT, JVM, "jvm.classes.loaded", + "Classes currently loaded")); + e.add(gauge("jvm.cpu.count", "{cpu}", INSTANT, JVM, "system.cpu.count", + "Processors available to the runtime")); + e.add(gauge("cn1.jvm.gc.collections", "{collection}", CUMULATIVE, JVM, + "jvm.gc.pause", "Garbage collections since start (the pause timer's count)")); + e.add(gauge("cn1.jvm.gc.time", "ms", CUMULATIVE, JVM, "jvm.gc.pause", + "Time spent collecting since start (the pause timer's total)")); + ENTRIES = Collections.unmodifiableList(e); + + List u = new ArrayList(); + u.add(unmeasured("jvm.gc.pause", "Not as a histogram of single pauses by collector and " + + "cause; cn1.jvm.gc.collections and cn1.jvm.gc.time carry its count and total " + + "on the JVM, and ParparVM's collector runs concurrently, without pauses to time.")); + u.add(unmeasured("jvm.gc.memory.allocated", "Neither runtime counts allocated bytes.")); + u.add(unmeasured("jvm.gc.memory.promoted", "Neither runtime has generations to promote " + + "between that the server reads.")); + u.add(unmeasured("jvm.memory.used{area,id}", "Memory is reported for the heap as a whole, " + + "not per memory pool.")); + u.add(unmeasured("jvm.buffer.memory.used", "Direct buffers are not tracked.")); + u.add(unmeasured("jvm.threads.states", "Thread states are not tracked; requests run on " + + "virtual threads, which http.server.active_requests counts.")); + u.add(unmeasured("process.cpu.usage", "CPU time is not read on either runtime.")); + u.add(unmeasured("system.cpu.usage", "CPU time is not read on either runtime.")); + u.add(unmeasured("system.load.average.1m", "The system load is not read.")); + u.add(unmeasured("process.files.open", "Open descriptors are not counted; " + + "http.server.open_connections counts the client sockets.")); + u.add(unmeasured("hikaricp.connections.usage", "Connection hold times are not timed.")); + u.add(unmeasured("hikaricp.connections.acquire", "Borrow waits are not timed; " + + "db.client.connection.pending_requests counts the borrowers waiting.")); + u.add(unmeasured("logback.events", "Log events are not counted.")); + UNMEASURED = Collections.unmodifiableList(u); + } + + private MetricSchema() { + } + + private static Entry gauge(String name, String unit, String temporality, + String availability, String micrometer, String description) { + return new Entry(name, Instrument.GAUGE, unit, NONE, temporality, availability, + micrometer, description); + } + + private static Entry executor(String name, String unit, String temporality, + String micrometer, String description) { + return new Entry(name, Instrument.GAUGE, unit, new String[] {"cn1.executor"}, + temporality, ALWAYS, micrometer, description + ", by executor"); + } + + private static String[] unmeasured(String micrometer, String reason) { + return new String[] {micrometer, reason}; + } + + /// Every instrument of the schema, in a fixed order. + public static List entries() { + return ENTRIES; + } + + /// The schema's instrument of this name, or null. + public static Entry get(String name) { + for (Object element : ENTRIES) { + Entry e = (Entry) element; + if (e.name.equals(name)) { + return e; + } + } + return null; + } + + /// The Micrometer meters a Spring Boot dashboard may read that no runtime + /// here reports, each as `{meter, reason}`. + public static List unmeasured() { + return UNMEASURED; + } + + /// The runtime this process is: `jvm` or `native`. + public static String runtime() { + return com.codename1.impl.backend.RuntimeGauges.runtime(); + } + + /// Whether this process reports the instrument now: it is registered and, + /// for a gauge without attributes, has its point to report. A gauge with + /// attributes has a point per value -- an executor, say -- and none while + /// there is no such value yet, which is a measured "none". False for every + /// instrument before a server has started. + public static boolean isAvailable(Entry entry) { + Instrument i = Metrics.get(entry.name); + if (i == null) { + return false; + } + return entry.kind != Instrument.GAUGE || entry.attributes.length > 0 + || !i.points().isEmpty(); + } + + /// The schema as the management endpoint serves it: the runtime, every + /// instrument with whether this process reports it and, when it does not, + /// why, and the Micrometer meters nothing here reports. + public static Map describe() { + Map out = new LinkedHashMap(); + out.put("runtime", runtime()); + List metrics = new ArrayList(); + for (Object element : ENTRIES) { + Entry e = (Entry) element; + Map m = new LinkedHashMap(); + m.put("name", e.name); + m.put("prometheus", e.getPrometheusName()); + m.put("kind", Metrics.kindName(e.kind)); + m.put("unit", e.unit); + List attributes = new ArrayList(); + for (String a : e.attributes) { + attributes.add(a); + } + m.put("attributes", attributes); + m.put("temporality", e.temporality); + m.put("availability", e.availability); + if (e.micrometer != null) { + m.put("micrometer", e.micrometer); + } + m.put("description", e.description); + boolean available = isAvailable(e); + m.put("available", Boolean.valueOf(available)); + if (!available) { + m.put("reason", reason(e)); + } + metrics.add(m); + } + out.put("metrics", metrics); + List unmeasured = new ArrayList(); + for (Object element : UNMEASURED) { + String[] u = (String[]) element; + Map m = new LinkedHashMap(); + m.put("micrometer", u[0]); + m.put("reason", u[1]); + unmeasured.add(m); + } + out.put("unmeasured", unmeasured); + return out; + } + + private static String reason(Entry e) { + if (JVM.equals(e.availability) && !"jvm".equals(runtime())) { + return "Measured on the JVM only; this runtime has no equivalent."; + } + if (DATABASE.equals(e.availability)) { + return "This server has no database pool."; + } + if (Metrics.get(e.name) == null) { + return "The server is not recording its own metrics."; + } + return "The runtime could not read it."; + } +} diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index 815c3a4f015..75975208bd6 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -55,7 +55,8 @@ /// metrics are on -- every request is measured in /// `http.server.request.duration` (milliseconds, by route template, method /// and status), and the server's counters, the database pool, the task executors -/// and the process's memory are published as gauges. +/// and the process's memory are published as gauges. [MetricSchema] lists each +/// one, with its unit, attributes and the runtimes that measure it. public final class Metrics { static { com.codename1.impl.backend.MetricsAccess.install(new com.codename1.impl.backend.MetricsAccess() { @@ -438,7 +439,9 @@ static void enableServer(final com.codename1.backend.HttpServer server, if (!LIVE_SERVERS.contains(server)) { LIVE_SERVERS.add(server); } - if (pool != null && !LIVE_POOLS.contains(pool)) { + // One entry per server using the pool, so a pool two servers share + // stays counted until the last of them stops; poolSum counts it once. + if (pool != null) { LIVE_POOLS.add(pool); } } @@ -458,49 +461,23 @@ static void enableServer(final com.codename1.backend.HttpServer server, "Requests answered since start", "{request}"); serverMetric("cn1.server.connections_refused", "connectionsRefused", "Connections refused for being over the limit", "{connection}"); - gauge("db.client.connection.count", "Open database connections", "{connection}", - new Gauge.Source() { - @Override - public double read() { - return poolSum(false); - } - }); - gauge("db.client.connection.idle", "Idle database connections", "{connection}", - new Gauge.Source() { - @Override - public double read() { - return poolSum(true); - } - }); - gauge("cn1.task.queue_depth", "Tasks waiting for a thread, by executor", "{task}", - new Gauge.MultiSource() { - @Override - public List read() { - // Summed by name: two servers in the process each have a - // "default" executor, and two points with one label set are one - // series twice -- duplicate samples a scrape rejects. - Map byName = new LinkedHashMap(); - // Only the servers that measure: one with metrics off keeps - // its queues out of the others' telemetry, as its - // requests and jobs are. - List all = com.codename1.impl.backend.BackendAccess.get().executorsOf(liveServers()); - for (Object element : all) { - com.codename1.backend.TaskExecutor e = - (com.codename1.backend.TaskExecutor) element; - Long sum = (Long) byName.get(e.getName()); - byName.put(e.getName(), Long.valueOf((sum == null ? 0 : sum.longValue()) - + e.getQueueDepth())); - } - List out = new ArrayList(); - Iterator names = byName.entrySet().iterator(); - while (names.hasNext()) { - Map.Entry entry = (Map.Entry) names.next(); - out.add(Gauge.point("cn1.executor", (String) entry.getKey(), - ((Long) entry.getValue()).longValue())); - } - return out; - } - }); + poolMetric("db.client.connection.count", POOL_OPEN, "Open database connections", + "{connection}"); + poolMetric("db.client.connection.idle", POOL_IDLE, "Idle database connections", + "{connection}"); + poolMetric("db.client.connection.max", POOL_MAX, "Connections the pool may hold", + "{connection}"); + poolMetric("db.client.connection.pending_requests", POOL_PENDING, + "Borrowers waiting for a connection", "{request}"); + poolMetric("db.client.connection.timeouts", POOL_TIMEOUTS, + "Borrows that gave up waiting for a connection", "{timeout}"); + executorMetric("cn1.task.queue_depth", EXECUTOR_QUEUED, + "Tasks waiting for a thread, by executor", "{task}"); + executorMetric("cn1.task.active", EXECUTOR_ACTIVE, "Tasks running, by executor", "{task}"); + executorMetric("cn1.task.completed", EXECUTOR_COMPLETED, + "Tasks finished since start, by executor", "{task}"); + executorMetric("cn1.task.pool_size", EXECUTOR_SIZE, + "Threads the executor runs tasks on, by executor", "{thread}"); gauge("process.runtime.memory.used", "Heap in use", "By", new Gauge.Source() { @Override public double read() { @@ -508,6 +485,26 @@ public double read() { return r.totalMemory() - r.freeMemory(); } }); + gauge("process.runtime.memory.committed", "Heap the runtime holds from the system", "By", + new Gauge.Source() { + @Override + public double read() { + return Runtime.getRuntime().totalMemory(); + } + }); + // What only some runtimes can measure is registered only where it can be: + // an instrument absent from the views is a figure this runtime does not + // have, which a zero would misreport as measured. MetricSchema says which. + runtimeMetric("process.runtime.memory.limit", RUNTIME_HEAP_LIMIT, + "The most heap the process may use", "By"); + runtimeMetric("jvm.thread.count", RUNTIME_THREADS, "Live platform threads", "{thread}"); + runtimeMetric("jvm.class.count", RUNTIME_CLASSES, "Classes currently loaded", "{class}"); + runtimeMetric("jvm.cpu.count", RUNTIME_CPUS, "Processors available to the runtime", + "{cpu}"); + runtimeMetric("cn1.jvm.gc.collections", RUNTIME_GC_COUNT, + "Garbage collections since start", "{collection}"); + runtimeMetric("cn1.jvm.gc.time", RUNTIME_GC_TIME, "Time spent collecting since start", + "ms"); gauge("process.uptime", "Seconds since the process started", "s", new Gauge.Source() { @Override public double read() { @@ -538,19 +535,150 @@ private static synchronized List liveServers() { return new ArrayList(LIVE_SERVERS); } - private static double poolSum(boolean idle) { - List pools; + private static final int POOL_OPEN = 0; + private static final int POOL_IDLE = 1; + private static final int POOL_MAX = 2; + private static final int POOL_PENDING = 3; + private static final int POOL_TIMEOUTS = 4; + + /// A gauge summed over the live servers' pools. With no pool it reads NaN, so + /// it reports no point: a server without a database has no pool to measure, + /// and a zero would read as an empty one. + private static void poolMetric(String name, final int which, String description, + String unit) { + gauge(name, description, unit, new Gauge.Source() { + @Override + public double read() { + return poolSum(which); + } + }); + } + + private static double poolSum(int which) { + List pools = new ArrayList(); synchronized (Metrics.class) { - pools = new ArrayList(LIVE_POOLS); + for (Object p : LIVE_POOLS) { + if (!pools.contains(p)) { + pools.add(p); + } + } + } + if (pools.isEmpty()) { + return Double.NaN; } double sum = 0; for (Object element : pools) { com.codename1.backend.DataSource p = (com.codename1.backend.DataSource) element; - sum += idle ? p.getIdleCount() : p.getOpenCount(); + switch (which) { + case POOL_OPEN: + sum += p.getOpenCount(); + break; + case POOL_IDLE: + sum += p.getIdleCount(); + break; + case POOL_MAX: + sum += p.getMaxSize(); + break; + case POOL_PENDING: + sum += p.getPendingCount(); + break; + default: + sum += p.getTimeoutCount(); + break; + } } return sum; } + private static final int EXECUTOR_QUEUED = 0; + private static final int EXECUTOR_ACTIVE = 1; + private static final int EXECUTOR_COMPLETED = 2; + private static final int EXECUTOR_SIZE = 3; + + /// A gauge with a point per executor name, over the live servers. + private static void executorMetric(String name, final int which, String description, + String unit) { + gauge(name, description, unit, new Gauge.MultiSource() { + @Override + public List read() { + // Summed by name: two servers in the process each have a + // "default" executor, and two points with one label set are one + // series twice -- duplicate samples a scrape rejects. + Map byName = new LinkedHashMap(); + // Only the servers that measure: one with metrics off keeps its + // executors out of the others' telemetry, as its requests and + // jobs are. + List all = com.codename1.impl.backend.BackendAccess.get().executorsOf(liveServers()); + for (Object element : all) { + com.codename1.backend.TaskExecutor e = (com.codename1.backend.TaskExecutor) element; + long v; + switch (which) { + case EXECUTOR_QUEUED: + v = e.getQueueDepth(); + break; + case EXECUTOR_ACTIVE: + v = e.getActiveCount(); + break; + case EXECUTOR_COMPLETED: + v = e.getCompletedCount(); + break; + default: + v = e.getSize(); + break; + } + Long sum = (Long) byName.get(e.getName()); + byName.put(e.getName(), Long.valueOf((sum == null ? 0 : sum.longValue()) + v)); + } + List out = new ArrayList(); + Iterator names = byName.entrySet().iterator(); + while (names.hasNext()) { + Map.Entry entry = (Map.Entry) names.next(); + out.add(Gauge.point("cn1.executor", (String) entry.getKey(), + ((Long) entry.getValue()).longValue())); + } + return out; + } + }); + } + + private static final int RUNTIME_HEAP_LIMIT = 0; + private static final int RUNTIME_THREADS = 1; + private static final int RUNTIME_CLASSES = 2; + private static final int RUNTIME_CPUS = 3; + private static final int RUNTIME_GC_COUNT = 4; + private static final int RUNTIME_GC_TIME = 5; + + private static double runtimeReading(int which) { + switch (which) { + case RUNTIME_HEAP_LIMIT: + return com.codename1.impl.backend.RuntimeGauges.heapLimit(); + case RUNTIME_THREADS: + return com.codename1.impl.backend.RuntimeGauges.threadCount(); + case RUNTIME_CLASSES: + return com.codename1.impl.backend.RuntimeGauges.classCount(); + case RUNTIME_CPUS: + return com.codename1.impl.backend.RuntimeGauges.cpuCount(); + case RUNTIME_GC_COUNT: + return com.codename1.impl.backend.RuntimeGauges.gcCount(); + default: + return com.codename1.impl.backend.RuntimeGauges.gcTime(); + } + } + + /// A gauge over a runtime reading, registered only when this runtime has it. + private static void runtimeMetric(String name, final int which, String description, + String unit) { + if (Double.isNaN(runtimeReading(which))) { + return; + } + gauge(name, description, unit, new Gauge.Source() { + @Override + public double read() { + return runtimeReading(which); + } + }); + } + private static void serverMetric(String name, final String key, String description, String unit) { gauge(name, description, unit, new Gauge.Source() { @@ -614,6 +742,9 @@ public static Map snapshot() { List all = instruments(); for (Object element : all) { Instrument i = (Instrument) element; + if (unmeasured(i)) { + continue; + } Map m = new LinkedHashMap(); m.put("kind", kindName(i.getKind())); if (i.getDescription().length() > 0) { @@ -628,6 +759,19 @@ public static Map snapshot() { return out; } + /// A schema gauge that only some servers measure (a pool gauge on a server + /// with no database) and that reads no point here is left out of every view, + /// never shown as an instrument with no value. Everything else keeps its name + /// before its first point: an executor gauge has no series until an executor + /// exists, and a counter none until its first recording. + public static boolean unmeasured(Instrument i) { + if (i.getKind() != Instrument.GAUGE) { + return false; + } + MetricSchema.Entry e = MetricSchema.get(i.getName()); + return e != null && !MetricSchema.ALWAYS.equals(e.getAvailability()) && i.points().isEmpty(); + } + static String kindName(int kind) { switch (kind) { case Instrument.COUNTER: return "counter"; @@ -643,6 +787,9 @@ public static String prometheus() { List all = instruments(); for (Object entry : all) { Instrument i = (Instrument) entry; + if (unmeasured(i)) { + continue; + } String name = promName(i.getName()); String type; switch (i.getKind()) { diff --git a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java index ce8d2868dcd..a84177df742 100644 --- a/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java +++ b/vm/backend/src/com/codename1/backend/otel/OtlpMetricExporter.java @@ -455,6 +455,10 @@ static Map request(Map resource, List instruments, long nowMillis) { if (instrument.getUnit().length() > 0) { metric.put("unit", instrument.getUnit()); } + if (Metrics.unmeasured(instrument)) { + // Nothing to measure on this server (MetricSchema): absent, not zero. + continue; + } List points = instrument.points(); List dataPoints = new ArrayList(points.size()); for (Object entry : points) { diff --git a/vm/backend/src/com/codename1/impl/backend/Management.java b/vm/backend/src/com/codename1/impl/backend/Management.java index 596f569ef1f..acc868ffb8c 100644 --- a/vm/backend/src/com/codename1/impl/backend/Management.java +++ b/vm/backend/src/com/codename1/impl/backend/Management.java @@ -35,14 +35,18 @@ import java.util.List; import java.util.Map; +import com.codename1.backend.metrics.MetricSchema; import com.codename1.backend.metrics.Metrics; /// The server's management endpoints: health for the load balancer, metrics for /// a scraper, and the jobs and managed beans for an operator. /// /// ```java -/// GET /manage/health public; 503 while draining +/// GET /manage/health public; 503 while starting or draining +/// GET /manage/health/readiness public; the same answer, for a readiness probe +/// GET /manage/health/liveness public; 200 for as long as the process serves /// GET /manage/metrics every metric, as JSON +/// GET /manage/schema the built-in metrics' schema, and what this runtime lacks /// GET /manage/prometheus every metric, in the Prometheus text format /// GET /manage/jobs the scheduled jobs and their last runs /// GET /manage/managed the managed beans, with their attributes' values @@ -219,9 +223,13 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { return null; } String method = request.getMethod(); - if ("/health".equals(rest) && ("GET".equals(method) || "HEAD".equals(method))) { + if (("/health".equals(rest) || "/health/readiness".equals(rest)) + && ("GET".equals(method) || "HEAD".equals(method))) { return health(request); } + if ("/health/liveness".equals(rest) && ("GET".equals(method) || "HEAD".equals(method))) { + return liveness(request); + } if (rest.length() == 0) { return null; } @@ -235,6 +243,9 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { if ("/metrics".equals(rest)) { return request.respondJson(200, Metrics.snapshot()); } + if ("/schema".equals(rest)) { + return request.respondJson(200, MetricSchema.describe()); + } if ("/prometheus".equals(rest)) { return request.respond(200, "text/plain; version=0.0.4; charset=utf-8", utf8(Metrics.prometheus())); @@ -316,6 +327,16 @@ private HttpServer.Response health(HttpServer.Request request) { return request.respondJson(up ? 200 : 503, out); } + /// Whether the process should be left running, as a liveness probe asks: + /// UP while it starts, serves and drains. A draining server is finishing its + /// requests, and a probe that restarted it then would cut them off; readiness + /// is what takes it out of the load balancer. + private HttpServer.Response liveness(HttpServer.Request request) { + Map out = new LinkedHashMap(); + out.put("status", "UP"); + return request.respondJson(200, out); + } + private boolean authorized(HttpServer.Request request, boolean operation) { if (token == null) { return development && !operation; diff --git a/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java b/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java index 93bdeabf118..81fe797df08 100644 --- a/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java +++ b/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java @@ -25,6 +25,8 @@ import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; import org.junit.jupiter.api.extension.TestInstancePostProcessor; /// The JUnit 5 side of `@BackendTest`: finds the context the build generated for @@ -36,7 +38,7 @@ /// reflection. The compiled half has no reflection and no JUnit; the build writes /// the same calls into a generated runner instead. public final class BackendTestExtension implements TestInstancePostProcessor, - BeforeEachCallback, AfterEachCallback { + BeforeEachCallback, AfterEachCallback, InvocationInterceptor { private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(BackendTestExtension.class); private static boolean hookInstalled; @@ -88,6 +90,36 @@ public void afterEach(ExtensionContext extension) throws Exception { } } + /// The test method alone, in a transaction rolled back after it, when the + /// context asks for one: the compiled runner makes the same call through + /// [TestTransactions], so the two runs leave the database alike. + @Override + public void interceptTestMethod(Invocation invocation, + ReflectiveInvocationContext method, + ExtensionContext extension) throws Throwable { + Object stored = extension.getStore(NAMESPACE).get("environment"); + if (!(stored instanceof TestEnvironment) + || !((TestEnvironment) stored).context().rollsBack()) { + invocation.proceed(); + return; + } + TestTransactions.rollingBack(new Proceed(invocation)); + } + + /// The intercepted test method, as the body [TestTransactions] runs. + private static final class Proceed implements TestTransactions.Body { + private final Invocation invocation; + + Proceed(Invocation invocation) { + this.invocation = invocation; + } + + @Override + public void run() throws Throwable { + invocation.proceed(); + } + } + /// The generated context of `testClass`. static TestContext load(Class testClass) throws Exception { String name = contextName(testClass.getName()); diff --git a/vm/backend/test/src/com/codename1/backend/test/BackendTest.java b/vm/backend/test/src/com/codename1/backend/test/BackendTest.java index 7a8ab8909e3..2064371a1b0 100644 --- a/vm/backend/test/src/com/codename1/backend/test/BackendTest.java +++ b/vm/backend/test/src/com/codename1/backend/test/BackendTest.java @@ -77,6 +77,31 @@ /// class of the test class is added without being listed. Class[] classes() default {}; + /// The production features to start the test's application with. None by + /// default: a test application has no management endpoints, MCP endpoint or + /// OpenTelemetry exporters, whatever the module's settings ask for, so a test + /// that does not exercise them does not pay for them. Name the ones a test + /// verifies, or [Feature#AS_PACKAGED] for the set the packaged server is + /// built with. + Feature[] features() default {}; + + /// A production feature [#features] can add to the test's application. + enum Feature { + /// The management endpoints, at `cn1.management.path` and with + /// `cn1.management.token`, as a test's settings give them. Turning them + /// on also has the server record its own metrics. + MANAGEMENT, + /// The OpenTelemetry exporters, for traces and metrics, sending where + /// the test's `cn1.otel.*` settings point -- an [OtlpTestCollector], say. + TELEMETRY, + /// The MCP endpoint, with the application's `@McpTool` methods. + MCP, + /// Each of the above the module's build links into the packaged server: + /// what `cn1.management.enabled`, `cn1.otel.enabled` or + /// `@OpenTelemetry`, and `cn1.mcp.enabled` or an `@McpTool` turn on. + AS_PACKAGED + } + /// How a [BackendTest] reaches the application. enum WebEnvironment { /// In process, through [MockMvc]: no request crosses a socket. diff --git a/vm/backend/test/src/com/codename1/backend/test/ContractChecks.java b/vm/backend/test/src/com/codename1/backend/test/ContractChecks.java new file mode 100644 index 00000000000..64a80fd3092 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ContractChecks.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// The assertions the contract interfaces make. Their own, rather than +/// JUnit's, so a contract fails the same way compiled, where JUnit's +/// assertion library is not linked. +final class ContractChecks { + private ContractChecks() { + } + + static void check(String what, boolean holds) { + if (!holds) { + throw new AssertionError(what); + } + } + + static void equal(String what, int expected, int actual) { + if (expected != actual) { + throw new AssertionError(what + ": expected " + expected + " but was " + actual); + } + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/DataJpaTest.java b/vm/backend/test/src/com/codename1/backend/test/DataJpaTest.java new file mode 100644 index 00000000000..95b830943ca --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/DataJpaTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/// Tests persistence without the rest of the application: what Spring's +/// `@DataJpaTest` does. +/// +/// The slice has the database, the entity manager and the module's repositories, +/// and none of its components, controllers or routes. A test injects the +/// `EntityManager`, the `DataSource` or a repository interface and calls it +/// directly. Components it needs too are named in [#includes]; a dependency of +/// theirs that the slice lacks fails the build. +/// +/// The database is the one [#database] names, a fresh in-memory SQLite one by +/// default, whatever the module's settings say, so a persistence test never +/// touches a database that outlives it. The generated daos create their tables +/// in it, and migrations run as they do on start-up. +/// +/// Each test method runs in a transaction that is rolled back when it returns, +/// as Spring's are, so one test's rows are not the next one's. `@BeforeEach` and +/// `@AfterEach` methods run outside it; [#rollback] set to false commits instead. +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ExtendWith(com.codename1.impl.backend.test.BackendTestExtension.class) +@org.junit.jupiter.api.parallel.ResourceLock("com.codename1.backend.test.BackendTest") +public @interface DataJpaTest { + /// Components and configurations of the application to wire into the slice, + /// what Spring's `@Import` adds to one. + Class[] includes() default {}; + + /// The database URL, as `cn1.datasource.url` takes it. An in-memory SQLite + /// database by default, which is gone when the test run ends. + String database() default ":memory:"; + + /// Whether each test method's transaction is rolled back. + boolean rollback() default true; + + /// Settings for this test, as `key=value`, as [BackendTest#properties]. + String[] properties() default {}; + + /// The profile, as [BackendTest#profile]. + String profile() default "test"; + + /// [TestConfiguration] classes to add, as [BackendTest#classes]. + Class[] classes() default {}; +} diff --git a/vm/backend/test/src/com/codename1/backend/test/ManagementContract.java b/vm/backend/test/src/com/codename1/backend/test/ManagementContract.java new file mode 100644 index 00000000000..7766e2b640c --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ManagementContract.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.io.IOException; + +/// The operational contract of a server with management on, as tests a test +/// class inherits: implement it on a [BackendTest] with +/// [BackendTest.Feature#MANAGEMENT] and a port, and its default `@Test` methods +/// check the probes, the endpoint authentication, the metric schema and the +/// Prometheus scrape against that application. +/// +/// ```java +/// @BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT, +/// features = BackendTest.Feature.MANAGEMENT, +/// properties = "cn1.management.token=s3cret") +/// class OperationsTest implements ManagementContract { +/// @Autowired TestRestTemplate rest; +/// public TestRestTemplate managementClient() { return rest; } +/// public String managementToken() { return "s3cret"; } +/// } +/// ``` +/// +/// The same class runs on the JVM and compiled, so a packaged server answers +/// a dashboard and a load balancer the way the JVM one does. +public interface ManagementContract { + /// A client rooted at the application, a [TestRestTemplate] the test + /// injected. + TestRestTemplate managementClient(); + + /// The `cn1.management.token` the test configured. + String managementToken(); + + /// The `cn1.management.path` the test configured. + default String managementPath() { + return "/manage"; + } + + /// The status `path` under the management path answers, with `token` as a + /// bearer token, or with none when it is null. + default int managementStatus(String path, String token) throws IOException { + return managementGet(path, token).getStatusCodeValue(); + } + + /// The answer to `GET` of `path` under the management path. + default ResponseEntity managementGet(String path, String token) throws IOException { + HttpHeaders headers = new HttpHeaders(); + if (token != null) { + headers.setBearerAuth(token); + } + return managementClient().exchange(managementPath() + path, HttpMethod.GET, + new HttpEntity(headers), String.class); + } + + /// Liveness and readiness answer a probe that carries no token, as an + /// orchestrator's does. + @org.junit.jupiter.api.Test + default void probesNeedNoToken() throws IOException { + ContractChecks.equal("liveness", 200, managementStatus("/health/liveness", null)); + ContractChecks.equal("readiness", 200, managementStatus("/health/readiness", null)); + ContractChecks.equal("health", 200, managementStatus("/health", null)); + } + + /// Everything else refuses a missing or a wrong token and serves the right one. + @org.junit.jupiter.api.Test + default void protectedEndpointsNeedTheToken() throws IOException { + String[] paths = {"/schema", "/metrics", "/prometheus"}; + for (String path : paths) { + ContractChecks.equal(path + " without a token", 401, managementStatus(path, null)); + ContractChecks.equal(path + " with a wrong token", 401, + managementStatus(path, managementToken() + "-wrong")); + ContractChecks.equal(path + " with the token", 200, + managementStatus(path, managementToken())); + } + } + + /// The schema names the runtime and describes each metric with its unit and + /// whether this server measures it. + @org.junit.jupiter.api.Test + default void theSchemaDescribesItsMetrics() throws IOException { + String body = managementGet("/schema", managementToken()).getBody(); + ContractChecks.check("no runtime in " + body, body.contains("\"runtime\"")); + String[] always = {"http.server.request.duration", "http.server.active_requests", + "process.runtime.memory.used", "process.uptime"}; + for (String name : always) { + ContractChecks.check(name + " missing from the schema", body.contains("\"" + name + "\"")); + } + ContractChecks.check("no units in the schema", body.contains("\"unit\"")); + ContractChecks.check("no availability in the schema", body.contains("\"available\"")); + } + + /// The scrape types its families and carries the metrics every runtime has. + @org.junit.jupiter.api.Test + default void prometheusScrapeIsTyped() throws IOException { + String body = managementGet("/prometheus", managementToken()).getBody(); + ContractChecks.check("untyped scrape", body.contains("# TYPE ")); + ContractChecks.check("no uptime in the scrape", body.contains("process_uptime")); + ContractChecks.check("no active requests in the scrape", + body.contains("http_server_active_requests")); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/OtlpTestCollector.java b/vm/backend/test/src/com/codename1/backend/test/OtlpTestCollector.java new file mode 100644 index 00000000000..e65a7dbadfa --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/OtlpTestCollector.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import com.codename1.backend.HttpServer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// An OTLP/HTTP receiver for tests: what a test points the exporters of a +/// [BackendTest.Feature#TELEMETRY] application at, to see that they send and +/// that they flush when the application stops. +/// +/// It accepts every POST, answers 200 with an empty body, and keeps each +/// request's path and body. It decodes nothing -- the exporters' encoding has its +/// own tests -- so a test asserts on what arrived where, and how much. +/// +/// ```java +/// static final OtlpTestCollector COLLECTOR = OtlpTestCollector.start(); +/// +/// @BackendTest(features = BackendTest.Feature.TELEMETRY, +/// properties = {"cn1.otel.endpoint=http://127.0.0.1:4318", +/// "cn1.otel.metrics.intervalMillis=200"}) +/// ``` +/// +/// A fixed endpoint in `properties` needs a fixed port: [#start(int)] takes one. +/// Runs on the JVM and compiled alike; it is an ordinary [HttpServer]. +public final class OtlpTestCollector { + private final HttpServer server; + private final List paths = new ArrayList(); + private final List bodies = new ArrayList(); + + private OtlpTestCollector(int port) throws IOException { + server = HttpServer.start("127.0.0.1", port, 64, 2, new HttpServer.Handler() { + @Override + public HttpServer.Response handle(HttpServer.Request request) { + record(request.getTarget(), request.getBodyBytes()); + return HttpServer.Response.text(200, ""); + } + }); + } + + /// A collector on a free loopback port. + public static OtlpTestCollector start() throws IOException { + return new OtlpTestCollector(0); + } + + /// A collector on `port`, for an endpoint a test names in its properties. + public static OtlpTestCollector start(int port) throws IOException { + return new OtlpTestCollector(port); + } + + /// The port it listens on. + public int getPort() { + return server.getPort(); + } + + /// The base URL for `cn1.otel.endpoint`, `http://127.0.0.1:`. + public String getEndpoint() { + return "http://127.0.0.1:" + getPort(); + } + + private synchronized void record(String path, byte[] body) { + int query = path.indexOf('?'); + paths.add(query < 0 ? path : path.substring(0, query)); + bodies.add(body == null ? new byte[0] : body); + notifyAll(); + } + + /// How many requests arrived at `path`, `/v1/traces` or `/v1/metrics`. + public synchronized int count(String path) { + int n = 0; + for (String p : paths) { + if (p.equals(path)) { + n++; + } + } + return n; + } + + /// The bodies that arrived at `path`, oldest first. + public synchronized List bodies(String path) { + List out = new ArrayList(); + for (int i = 0; i < paths.size(); i++) { + if (paths.get(i).equals(path)) { + out.add(bodies.get(i)); + } + } + return out; + } + + /// Waits until `count` requests have arrived at `path`; false when + /// `timeoutMillis` passed first. + public synchronized boolean await(String path, int count, long timeoutMillis) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (count(path) < count) { + long left = deadline - System.currentTimeMillis(); + if (left <= 0) { + return false; + } + wait(left); + } + return true; + } + + /// Forgets what arrived so far. + public synchronized void clear() { + paths.clear(); + bodies.clear(); + } + + /// Stops listening. + public void stop() { + server.stop(); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/WebMvcTest.java b/vm/backend/test/src/com/codename1/backend/test/WebMvcTest.java new file mode 100644 index 00000000000..7e4b6858161 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/WebMvcTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/// Tests controllers without the rest of the application: what Spring's +/// `@WebMvcTest` does. +/// +/// The build wires the controllers this names -- every controller when it names +/// none -- with what serves their requests: the `@ControllerAdvice` and +/// `@RestControllerAdvice` classes, and the `HandlerInterceptor` and +/// `ExceptionResolver` components. No service, repository or database is +/// built. Whatever a controller needs beyond that comes from the test, as a +/// [MockitoBean], a [TestConfiguration] bean, or a class named in [#includes]. +/// A dependency nothing supplies fails the build, naming the controller and the +/// type it asked for, so a slice never starts half wired. +/// +/// The test reaches the controllers through [MockMvc], in process. +/// +/// ```java +/// @WebMvcTest(GreetingApi.class) +/// class GreetingApiSliceTest { +/// @Autowired MockMvc mvc; +/// @MockitoBean Greetings greetings; +/// ... +/// } +/// ``` +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ExtendWith(com.codename1.impl.backend.test.BackendTestExtension.class) +// The same lock as BackendTest: one application runs per process, whichever +// annotation started it. +@org.junit.jupiter.api.parallel.ResourceLock("com.codename1.backend.test.BackendTest") +public @interface WebMvcTest { + /// The controllers to test; every controller of the module when empty. + Class[] value() default {}; + + /// The same as [#value], for a test that reads better naming it. + Class[] controllers() default {}; + + /// Components and configurations of the application to wire into the slice + /// as well -- what Spring's `@Import` adds to one. Each must be a bean class + /// of the module. + Class[] includes() default {}; + + /// Settings for this test, as `key=value`, as [BackendTest#properties]. + String[] properties() default {}; + + /// The profile, as [BackendTest#profile]. + String profile() default "test"; + + /// [TestConfiguration] classes to add, as [BackendTest#classes]. + Class[] classes() default {}; +} diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java index e1c9f91ced0..0b205495130 100644 --- a/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java @@ -72,6 +72,11 @@ public interface TestContext { /// The names of the mock beans, reset after each test. String[] mockBeans(); + /// Whether each test method runs in a transaction that is rolled back + /// afterwards, as a `@DataJpaTest`'s do; its `@BeforeEach` and `@AfterEach` + /// methods run outside it. + boolean rollsBack(); + /// Fills the test instance's `@Autowired`, `@LocalServerPort` and /// `@MockitoBean` fields. void inject(Object test, TestEnvironment environment) throws Exception; diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestTransactions.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestTransactions.java new file mode 100644 index 00000000000..cfbf70ff3de --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestTransactions.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +import com.codename1.backend.Transactions; + +/// Runs a test method in a transaction that never commits: what a +/// `@DataJpaTest` does to each of its tests, on the JVM and compiled alike. +public final class TestTransactions { + private TestTransactions() { + } + + /// The test method's body. + public interface Body { + /// Runs it. + void run() throws Throwable; + } + + /// Runs `body` in a new outermost transaction and rolls it back, whether the + /// body returned or threw; what it threw is rethrown after the rollback. + /// Work the body hands to another thread is not in the transaction, which is + /// Spring's rule too. + public static void rollingBack(final Body body) throws Throwable { + final Throwable[] thrown = new Throwable[1]; + Transactions.execute(new Transactions.Work() { + @Override + public Object run() { + try { + body.run(); + } catch (Throwable err) { + thrown[0] = err; + } + Transactions.setRollbackOnly(); + return null; + } + }); + if (thrown[0] != null) { + throw thrown[0]; + } + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendOperationsTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendOperationsTest.java new file mode 100644 index 00000000000..cfedf65b512 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendOperationsTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The operational contract on the PACKAGED runtime: what a load balancer, a + * Prometheus scrape, an OTLP collector and the sample dashboard see of a + * translated server. The backend module's MetricSchemaTest holds the JVM arm to + * the same contract; this is the native one, built locally through build.sh -- + * no GraalVM, no native-image. + */ +class BackendOperationsTest { + private static final String TOKEN = "s3cret"; + /** The schema entries a native server cannot measure. */ + private static final String[] JVM_ONLY = {"jvm_thread_count", "jvm_class_count", "jvm_cpu_count", + "cn1_jvm_gc_collections_total", "cn1_jvm_gc_time_total", "process_runtime_memory_limit"}; + + @Test + @DisplayName("a translated server answers probes, guards its endpoints, serves the dashboard's metrics and flushes its exporter on stop") + void operationsOnThePackagedRuntime() throws Exception { + if (CompilerHelper.isWindows()) { + BackendTestSupport.skipOrFail("the server-side backend is POSIX-only for now"); + } + Path jdk8 = BackendTestSupport.findJdk8(); + BackendTestSupport.require(jdk8 != null, "no JDK 8 available to compile the backend"); + Path work = Files.createTempDirectory("backend-ops"); + Path binary = work.resolve("opsserver"); + String failure = BackendTestSupport.build("OpsServer", "demo/opscheck", binary, jdk8, true); + if (failure != null) { + BackendTestSupport.skipOrFail(failure); + } + + final AtomicInteger exports = new AtomicInteger(); + HttpServer collector = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + collector.createContext("/v1/metrics", (HttpExchange exchange) -> { + readAll(exchange.getRequestBody()); + exports.incrementAndGet(); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + collector.start(); + int port = BackendTestSupport.freePort(); + Map env = new HashMap(); + env.put("PORT", String.valueOf(port)); + env.put("CN1_DATASOURCE_URL", work.resolve("ops.db").toString()); + env.put("CN1_MANAGEMENT_ENABLED", "true"); + env.put("CN1_MANAGEMENT_TOKEN", TOKEN); + env.put("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:" + collector.getAddress().getPort()); + // Long enough that the shutdown flush is the export the test waits for. + env.put("OTEL_METRIC_EXPORT_INTERVAL", "600000"); + Path log = work.resolve("server.log"); + Process server = BackendTestSupport.start(binary, env, log); + try { + assertTrue(BackendTestSupport.waitForPort(port, 60000), + "the server never listened:\n" + read(log)); + String base = "http://127.0.0.1:" + port; + for (int i = 0; i < 5; i++) { + assertEquals(200, get(base + "/hello", null).status); + } + assertEquals(503, get(base + "/fail", null).status); + + // The probes need no token; everything else needs the right one. + assertEquals(200, get(base + "/manage/health/liveness", null).status); + assertEquals(200, get(base + "/manage/health/readiness", null).status); + for (String path : new String[] {"/manage/schema", "/manage/metrics", "/manage/prometheus"}) { + assertEquals(401, get(base + path, null).status, path + " without a token"); + assertEquals(401, get(base + path, "wrong").status, path + " with a wrong token"); + assertEquals(200, get(base + path, TOKEN).status, path + " with the token"); + } + + String schema = get(base + "/manage/schema", TOKEN).body; + assertTrue(schema.contains("\"runtime\":\"native\""), schema); + assertTrue(schema.contains("\"name\":\"jvm.thread.count\"") && schema.contains("\"available\":false"), + "the JVM-only figures are not marked unavailable: " + schema); + + String scrape = get(base + "/manage/prometheus", TOKEN).body; + for (String name : JVM_ONLY) { + assertFalse(scrape.contains("# TYPE " + name + " "), + name + " is scraped on a runtime that cannot measure it"); + } + // Every series the sample dashboard queries is served here, typed. + Set missing = new TreeSet(); + for (String name : dashboardMetrics()) { + String series = name.replaceAll("_(bucket|sum|count)$", ""); + if (!scrape.contains("# TYPE " + name + " ") + && !scrape.contains("# TYPE " + series + " histogram")) { + missing.add(name); + } + } + assertTrue(missing.isEmpty(), "the dashboard reads " + missing + + ", which the packaged server does not serve:\n" + scrape); + assertTrue(scrape.contains("http_server_request_duration_count{"), scrape); + assertTrue(scrape.contains("db_client_connection_max "), scrape); + + // SIGTERM: the server drains and the exporter sends what it holds. + assertEquals(0, exports.get(), "exported before the interval"); + server.destroy(); + assertTrue(server.waitFor(30, TimeUnit.SECONDS), "the server did not stop:\n" + read(log)); + assertTrue(exports.get() >= 1, "the exporter did not flush on stop:\n" + read(log)); + } finally { + BackendTestSupport.stop(server); + collector.stop(0); + } + } + + /** The metric names the sample dashboard's queries name. */ + private static Set dashboardMetrics() throws IOException { + String json = new String(Files.readAllBytes(BackendTestSupport.backendDir() + .resolve("dashboards/backend-overview.json")), StandardCharsets.UTF_8); + Set names = new TreeSet(); + Matcher m = Pattern.compile("([a-zA-Z_:][a-zA-Z0-9_:]*)\\{").matcher(json); + while (m.find()) { + String name = m.group(1); + if (name.contains("_")) { + names.add(name); + } + } + assertTrue(names.size() >= 10, "read too few names from the dashboard: " + names); + return names; + } + + private static final class Answer { + int status; + String body; + } + + private static Answer get(String url, String token) throws IOException { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + c.setConnectTimeout(10000); + c.setReadTimeout(10000); + if (token != null) { + c.setRequestProperty("Authorization", "Bearer " + token); + } + Answer a = new Answer(); + a.status = c.getResponseCode(); + InputStream in = a.status < 400 ? c.getInputStream() : c.getErrorStream(); + a.body = in == null ? "" : new String(readAll(in), StandardCharsets.UTF_8); + c.disconnect(); + return a; + } + + private static String read(Path log) { + try { + return new String(Files.readAllBytes(log), StandardCharsets.UTF_8); + } catch (IOException err) { + return "(no log: " + err + ")"; + } + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + for (int n; (n = in.read(buf)) > 0; ) { + out.write(buf, 0, n); + } + in.close(); + return out.toByteArray(); + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java index 9d8c32e08bf..63420d2ee85 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java @@ -90,12 +90,22 @@ static Path backendDir() { /** Builds one demo into `binary`. Returns null on success, or the reason. */ static String build(String mainClass, String demoDir, Path binary, Path jdk8) throws Exception { + return build(mainClass, demoDir, binary, jdk8, false); + } + + /// As [#build(String, String, Path, Path)]; `standalone` builds a demo that needs + /// neither the shared contract nor demo/common (CN1_BACKEND_STANDALONE_DEMO). + static String build(String mainClass, String demoDir, Path binary, Path jdk8, boolean standalone) + throws Exception { ProcessBuilder build = new ProcessBuilder("./build.sh", mainClass, "com.demo", binary.toString()); build.directory(backendDir().toFile()); build.environment().put("JDK_8_HOME", jdk8.toString()); build.environment().put("JAVA_HOME", jdk8.toString()); build.environment().put("CN1_BACKEND_DEMO", demoDir); + if (standalone) { + build.environment().put("CN1_BACKEND_STANDALONE_DEMO", "1"); + } build.redirectErrorStream(true); Process p = build.start(); boolean[] timedOut = new boolean[1]; From 84305c765e80a6b545b2de385aeb3e31ac46df4e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:45:17 +0200 Subject: [PATCH 2/2] docs: backend examples are not Playground snippets; Vale contractions Co-Authored-By: Claude Opus 5.5 --- docs/developer-guide/Backend-Observability.asciidoc | 2 +- docs/developer-guide/Backend-Testing.asciidoc | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 72efe45ac4b..42c37c1e7cd 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -299,7 +299,7 @@ recorded with status 500. runtime (`jvm` or `native`) and, for each metric, its kind, unit, attributes, temporality, Micrometer name and whether this server measures it. -==== Unavailable is not zero +==== An unmeasured metric is absent, not zero A metric a server can't measure isn't reported as `0`. Its instrument isn't registered, or reads no value, so it's absent from the JSON view, the Prometheus scrape and the OTLP diff --git a/docs/developer-guide/Backend-Testing.asciidoc b/docs/developer-guide/Backend-Testing.asciidoc index 9991b47e717..7eb4ae2c285 100644 --- a/docs/developer-guide/Backend-Testing.asciidoc +++ b/docs/developer-guide/Backend-Testing.asciidoc @@ -255,7 +255,7 @@ behind `MockMvc`. Nothing else is created: no other controller, repository, scheduled job or bean is constructed, so a bean whose constructor needs a database or a remote service can't slow the test down or break it. -[source,java] +[source,java,role=cn1-no-try] ---- include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingSliceTest.java[tag=backend-test-web-slice,indent=0] ---- @@ -270,7 +270,7 @@ build error too. `includes` lists, with no web layer (its environment is `NONE`). It runs against its own database, an in-memory SQLite one unless its `database` attribute names a file or a URL, and the test's own settings can't point it at a shared one by -accident. Each test method runs in a transaction that is rolled back when it +accident. Each test method runs in a transaction that rolls back when it returns, whether it passed or failed, so tests can't see each other's rows; `rollback = false` keeps what a test wrote. A test can inject a repository interface the application never implements and gets the generated one. @@ -297,7 +297,7 @@ compiled: `cn1.otel.endpoint` at its `getEndpoint()`, turn `TELEMETRY` on, and `await` the export requests the server sends to `/v1/traces` or `/v1/metrics`. -[source,java] +[source,java,role=cn1-no-try] ---- include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ManagedServerTest.java[tag=backend-test-management-contract,indent=0] ----